Guides

Styling a Soda app

An app has one stylesheet, /app.css, and one place its look is decided: the design tokens in its web/app.css. Change a token and everything follows, the app's own views and Soda's screens (signing in, sharing, security, the bar) alike. Nothing else may set a color, font, radius or shadow; the build refuses it.

How it's built#

The SDK's build (scripts/build.sh) compiles, with Tailwind CSS v4 (the standalone CLI, pinned and checksummed in scripts/tailwind.sh; no Node):

  1. web/soda.css from the SDK: Tailwind, every token's default, Soda's base styles and its controls (soda-btn, soda-input, soda-card, soda-chip).
  2. The app's own web/app.css, after it: the app's tokens, and anything it adds.

Sources are named, never guessed: Tailwind reads the app's Rust (src/, or instance/src for an app from before Soda) and web/ (and the SDK's own browser code) for the classes it uses. The result is dist/app.css, which the shell links (<link rel="stylesheet" href="/app.css">; a shell that doesn't is given the link) and the service worker keeps. Then scripts/tokens.mjs checks the app (below).

The tokens#

Tailwind's own palette, fonts, radii and shadows are gone: bg-blue-500 doesn't exist. What's left are Soda's tokens, each a CSS variable and a family of utilities. Colors are light-dark() pairs, so one set covers both schemes.

TokenWhat it colors or setsUtilities
--color-pageThe pagebg-page
--color-surfaceWhat sits on it: cards, sheets, Soda's screensbg-surface
--color-subtleA quieter fill: hovered rows, wells, chipsbg-subtle
--color-scrimBehind a sheetbg-scrim
--color-ink, --color-mutedText, and quieter texttext-ink, text-muted
--color-line, --color-line-strongBorders and rulesborder-line, divide-line, ring-line
--color-accent, --color-accent-inkThe one action color, and text on itbg-accent, text-accent-ink
--color-focusFocus rings (the accent unless set)outline-focus, ring-focus
--color-danger, --color-danger-inkDestructive actions and errorsbg-danger, text-danger
--color-warn, --color-warn-inkA warning notice: its ground and textbg-warn, text-warn-ink
--color-alert, --color-alert-inkAn alert notice: its ground and textbg-alert, text-alert-ink
--color-okDone, saved, mettext-ok
--font-sans, --font-display, --font-monoText, headings, code and numbers (Soda Sans by default: GitHub's Mona Sans, subset and renamed as its license asks)font-sans, font-display, font-mono
--text-uiSoda's screens' text sizetext-ui
--radius-tag, -control, -card, -sheet, -pillCorners, small to roundrounded-control, …
--shadow-raised, --shadow-overlayLifted a little; floatingshadow-raised, shadow-overlay
--container-contentHow wide the app's view runs under Soda's barmax-w-content

Soda's components have tokens of their own, each defaulting to the ones above: --color-bar, --color-status and --color-status-ink (the "Catching up…" status), --color-toast and --color-toast-ink, --color-countdown-soon, -over, -met, --color-history-ink, -stop, -train, --history-lightness and --history-chroma (the history line's segments), --person-lightness, --person-chroma and --color-person-ink (each person's color, from their hue), --ease-history and --landed (the animation a card plays when it lands).

An app adds tokens of its own the same way: --color-dose-given in its @theme is bg-dose-given in its views.

Restyling: web/app.css#

@theme {
  --color-accent: light-dark(oklch(0.58 0.17 45), oklch(0.78 0.13 55));
  --font-sans: "Atkinson Hyperlegible Next", ui-sans-serif, sans-serif;
  --radius-control: 999px;
}

That's a different app: every button, field, sheet and screen. A font of the app's own goes in web/fonts/ with its @font-face in app.css, and its license beside it. Subsetting an SIL OFL font makes it a modified version: if its license reserves a font name (Mona Sans reserves "Mona", Monaspace "Monaspace"), rename the subset's own name table, and keep its copyright notice.

A Rust char literal holding a double quote ('"') throws Tailwind's scanner off for the rest of that file, so the classes after it never reach the stylesheet: write it '\u{22}'. (The tokens check is what notices: every class after it shows up as missing.)

Tailwind leaves out a token no utility uses. One the app's code names some other way, a variable built from a slug (var(--color-pill-{slug}), say), needs its @theme to be @theme static, or it isn't there and what reads it falls back to nothing.

Converting a font: shorthand to utilities also needs normal-nums (or tabular-nums) when the shorthand reset the figures.

In the views#

maud writes an element's own name bare, and its utilities quoted beside it, all in one class attribute:

li.task."flex items-start gap-3 rounded-control px-3 py-2 hover:bg-subtle" { … }
button.save.(soda_local::cx!("soda-btn", "soda-btn-primary" => primary, "opacity-50" => busy)) { "Save" }
  • The bare name (.task, .save) is what tests, seed scripts and browser code find the element by. It isn't a utility and is never styled by itself.
  • Utilities are quoted (."…") or given to soda_local::cx!, which keeps the ones whose condition holds. Never write .task and class="…" on one element: that's two class attributes, and browsers keep only the first.
  • Write every class whole. Tailwind finds them by reading the source, so format!("bg-{c}") never reaches the stylesheet.
  • Soda's own looks are classes in the stylesheet's components layer (web/soda.css), each on the tokens, so a utility beside one always wins, with nothing to merge: button."soda-btn soda-btn-primary rounded-pill". Soda's components draw them (a <Button> is a soda-btn), and markup no component draws takes them as they are (a link or a <summary> that looks like a button). They're named soda-*, so an app's own classes (its .btn, its .chip) never meet them.
    • soda-btn, with soda-btn-primary, soda-btn-quiet, soda-btn-danger and soda-btn-sm
    • soda-input (fields, textareas, selects; red while an action says it's wrong)
    • soda-field, soda-label, soda-hint, soda-error (a labelled field)
    • soda-card (a bordered surface), soda-chip (a small rounded label)
    • soda-tabs (soda-tabs-pills for pills) and soda-tab, the current one aria-current="page"
    • soda-menu, soda-menu-item (aria-current="true" for the one in use), soda-menu-label
    • soda-empty, soda-empty-title (what a list says while there's nothing in it)
    • soda-avatar (someone's initials in their color), and on anything inside a data-hue (soda_local::ui::hue), the person's color as a token: bg-person, text-person, border-person
  • A class of the app's own CSS is named for what it is, never like a utility: a .grow rule would style every grow utility, and the utility every .grow. The check refuses one.

Tailwind v4.3's newer variants are there to use: aria-checked:, group-open:, pointer-coarse: (a touch screen), media queries of your own ([@media(max-height:520px)]:), not-*:, starting: (for @starting-style entrances), inert:, @container queries, *: and nth-*:. Arbitrary values are fine for sizes (p-[13px], flex-[1_1_220px]), weights (font-[520]) and shadows whose colors are tokens (shadow-[0_-1px_0_var(--color-line)]); never for colors, font families or radii.

Light and dark. light-dark() takes colors only, so a color token covers both schemes by itself, and so does a shadow token whose colors are light-dark() pairs. A value that isn't a color (a lightness, a size) gets two tokens and is chosen per scheme outside @theme, by reference only:

@theme { --history-lightness-day: 0.52; --history-lightness-night: 0.72; }
@layer base {
  :root { --history-lightness: var(--history-lightness-day); }
  @media (prefers-color-scheme: dark) { :root { --history-lightness: var(--history-lightness-night); } }
}

In a Soda view#

Soda's view! (see framework.md) writes classes as JSX and Svelte do, and an element's classes are always one attribute, however many ways they're given:

view! {
    <li class="item flex items-start gap-3 rounded-control px-3 py-2" class:opacity-60=item.done
        class:bg-accent/10=selected class=extra>…</li>
}
  • class="…" is literal, so Tailwind finds every class in it. An element's hook (what tests and browser code find it by: item) goes in it too, a single word, never styled by itself.
  • class:name=cond adds a class when cond holds; class:name alone always does. The name is any class Tailwind has: class:bg-accent/10, class:px-2.5, class:aria-[current=page]:font-bold. Tailwind can't tell class:bg-accent from a class with a class: variant, so the build lists these names for it (scripts/view-classes.mjs), and the check checks them like any other.
  • class=expr takes classes from a value: a component's class prop, or a const.

Classes given to a component win#

A component's classes are its own; a class given to it (<Button class="rounded-pill px-6">) wins wherever the two set the same thing, whatever order Tailwind wrote them in. cn joins the lists, leaving a class out when a later one sets everything it sets, under the same variant: px-6 after px-4, p-4 after px-6 (a shorthand covers its longhands), hover:bg-accent after hover:bg-subtle. Not px-6 after p-4 (it sets less, and Tailwind puts the shorthand first), nor bg-accent and hover:bg-subtle. A class the stylesheet doesn't have (a hook) is always kept.

There's no table of which utilities conflict to keep up with Tailwind. The build reads what each utility sets off the app's own compiled stylesheet (scripts/kit-styles.mjs writes app.classes.json), so it's exact for whatever the stylesheet holds, the app's own tokens and utilities included. The instance's soda::cn and the page's cn (from /soda-component.js) read the same map, and the SDK's tests run both on the same cases (kit/tests/cn.json).

The app's own browser components#

What has to run in the page is a Rocket component in web/components/, imported by web/kit.js, and written with Soda's component, which is Rocket's rocket with the app's utilities inside:

import { component } from '/soda-component.js'

component('pantry-expiry', {
  props: ({ number }) => ({ at: number }),
  parts: ['badge'],
  render: ({ html, props, part }) => html`
    <span class="${part('badge', 'inline-flex gap-1.5 rounded-pill bg-subtle px-2.5 text-sm')}">
      <soda-time at="${props.at}" format="ago"></soda-time>
    </span>`,
})
<pantry-expiry at="…" badge-class="bg-warn text-warn-ink"></pantry-expiry>
  • Its shadow root takes the app's stylesheet: one CSSStyleSheet, parsed once, shared by every component (app.styles.js, which the build writes). It's the stylesheet without the theme, @property and @font-face, which belong to the page: tokens reach into shadow roots by inheriting, so a token set on part of the page (a section's own accent) holds inside its components too.
  • Each part it names takes classes from outside, name-class="…", joined after its own by cn.
  • Name it for the app (pantry-…), never soda-….

What the shared sheet costs, measured 2026-10-16 with 500 components on a page (Chromium with its CPU slowed four times, about a phone): drawing them took 159 to 174 ms against 141 with no stylesheet at all, a token change restyled them in 14 to 15 ms against 8, and the page held 2 to 4 MB more. A sheet per component instead took 458 to 1,829 ms and 35 to 158 MB more. The size of the stylesheet barely matters for the shared one (the template's 27 KB and Care's 94 KB measured the same): rules are found by class. WebKit was in line (55 ms against 49, unthrottled).

Soda's own components#

(Tailwind's reset clears every element's margin, padding and border, and a page's rules beat a component's own :host rules, so soda.css gives every Rocket component's host its own back. An app's own Rocket components get the same, and an app that resets every element itself (*) leaves them alone: hosts are styled from the components layer or with utilities.)

soda-ui.js (the gate, start, bar, notices, share, security, recovery and toasts screens, and soda-dialog) and soda-elements.js (soda-time, soda-countdown, soda-lists and the rest) draw in shadow DOM, so the page's morphs never touch what's inside them. They read the tokens (custom properties reach into shadow roots), so the tokens restyle them. For one piece of one, style its part from app.css, with utilities if you like:

soda-bar::part(button primary) { @apply rounded-pill px-5; }

The parts each exports are listed in docs/building-an-app.md. For anything else, soda.theme(css) (from web/kit.js), or a <style id="soda-theme"> in the shell, adds rules inside every one of them, after their own.

Moving between pages#

A move to another page is a view transition (a crossfade, the browser's own, unless the app says otherwise); a move within one (a tab, a filter) is at once, and so is every move for someone who turns motion down. Restyle it in web/app.css with the View Transitions API's own pseudo-elements, and give what should travel between pages a name of its own:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 180ms;
}
/* The page's heading glides to its place on the next page, rather than fading. */
.page-title { view-transition-name: page-title; }

Reloads paint whole#

A reload goes straight from the last frame to the whole page: nothing pops in, moves or plays its entrance again. Three things do it, and the template's shell (web/index.html) has them all:

  • The shell's <head> loads /soda-whole.js (async blocking="render"), then /soda.js and /kit.js (blocking="render"). The service worker answers /soda-whole.js once soda.js says the page is whole: Datastar has read it, every component on it has drawn itself, its fonts are in. Until then the browser keeps the last frame up. (Never more than half a second. Before a worker is in charge, on a first visit, it holds nothing.) The build warns about a shell without it.
  • <html data-drawn> marks the page whole, and <html data-settled> marks it settled: drawn, loaded, two frames up. Until it's settled, soda.css finishes every transition and animation in the page at once, and soda.js finishes any the page started with when it settles. So entrances (starting:, animate-*) play for what arrives after: a new card, a toast, someone joining.
  • Anything that sets the page's layout is in the view the instance renders, never drawn later by the browser: what a component draws in its shadow root is there before the first paint, but what browser code adds after the stream connects is not.

The check#

scripts/tokens.mjs runs after the stylesheet is compiled, and refuses, with the file and line:

  • a raw color, font family, radius or shadow anywhere but a @theme block (or an @font-face): in web/*.css, in CSS the app's browser code writes, in inline styles in its Rust or HTML, and in the SDK's own components;
  • a bracketed one-off value carrying one: bg-[#ff0000], shadow-[0_1px_2px_red], rounded-[7px], font-[Georgia];
  • a utility the stylesheet doesn't have: a default the theme took out (bg-blue-500) or a typo (text-mutd), which Tailwind would otherwise leave out without a word;
  • a class in the page's CSS (web/*.css, a <style> in the shell) named like a utility (.block, .grow, .ring).

Classes are read where they're written: maud's quoted classes, soda_local::cx!, a const that's a class list, class="…", and the class: an app's browser code builds an element with.

A line that has a reason can opt out with a comment saying tokens: allow and why (a print style on white paper, say). SODA_TOKENS=report lists everything and lets the build go on, for an app partway onto tokens.

The old names#

Before tokens, apps set --soda-* properties. Soda's components still honor them, ahead of the tokens, for now. Move each to its token:

WasNow
--soda-bg--color-surface
--soda-surface--color-subtle
--soda-page--color-page
--soda-ink, --soda-muted, --soda-line--color-ink, --color-muted, --color-line
--soda-accent, --soda-accent-ink, --soda-focus--color-accent, --color-accent-ink, --color-focus
--soda-danger(-ink), --soda-warn(-ink), --soda-alert(-ink), --soda-ok--color-danger(-ink), …
--soda-font, --soda-mono, --soda-size--font-sans, --font-mono, --text-ui
--soda-radius, --soda-shadow--radius-control, --shadow-overlay
--soda-bar-bg, --soda-status-bg, --soda-status-ink--color-bar, --color-status, --color-status-ink
--soda-toast-bg, --soda-toast-ink--color-toast, --color-toast-ink
--soda-countdown-soon, -over, -met--color-countdown-soon, -over, -met
--soda-history-ink, -stop, -train, -line-l, -line-c, -travel--color-history-ink, -stop, -train, --history-lightness, --history-chroma, --ease-history
--soda-landed--landed

This page is docs/styling.md in the SDK, built into this site as it is.

Esc