Skip to content

Theming

Every visual value is a custom property. A theme is a block that redeclares them:

[data-theme="brand"] {
color-scheme: light;
--wayra-color-primary: oklch(62% 0.19 40);
--wayra-color-primary-content: oklch(99% 0.01 40);
--wayra-radius-field: 4px;
}
<html data-theme="brand">

That is the whole mechanism. There is no provider, no context and no JavaScript. A theme therefore applies to any subtree — a section, a card, a preview frame — not just to the document root.

Seven palettes ship with the stylesheet. The default (wayra) is declared at :root; the rest are [data-theme] blocks.

Each card below is the palette and nothing else: the three surfaces, then the eight role pairs — every rectangle filled with --wayra-color-<role> and lettered in its -content — and then the geometry drawn as the shapes it decides. A swatch you cannot read is a role that misses its own contrast contract, so the picture is the measurement.

wayra

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

A fixed system palette reproduced in oklch, 6px corners and tighter controls (--wayra-size-field: 0.22rem). This is the deliberate AA exception; see contrast below.

This one card carries no data-theme, because there is no [data-theme="wayra"] block to name — the default lives at :root. It therefore follows the site’s own theme switcher while the six below stay put, which is the same limitation the subtree aside describes, seen from the other side.

aurora

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

Cool desaturated blues and teals, the softest corners of the set (1.25rem field, 2rem box).

brutal

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

Flat, saturated fuchsia, violet and teal on near-white, 2px borders and near-square corners.

selva

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

Forest green and cyan on warm off-white, deep indigo-black in the dark. 0.375rem corners. The founding palette, and the default before wayra took :root.

sunset

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

Magenta, cyan and amber on violet-black. The most saturated of the set.

terra

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

Terracotta and moss on toasted paper. Generous corners (0.75rem field, 1.5rem box).

volt

  • base-100
  • base-200
  • base-300
  • base-content
  • primary
  • secondary
  • accent
  • neutral
  • info
  • success
  • warning
  • error
  • --wayra-border
  • --wayra-radius-selector
  • --wayra-radius-field
  • --wayra-radius-box

Near-monochrome with a volt-lime accent. Tight, low-chroma, 0.5rem corners.

Surfaces are the neutral backdrop everything else sits on:

Token Role
--wayra-color-base-100 resting surface
--wayra-color-base-200 one step away (nested cards, secondary UI)
--wayra-color-base-300 two steps away, also the default border
--wayra-color-base-content text legible on all three

Then eight roles, each one a pair: the surface color, and the -content that stays legible on top of it. They are primary, secondary, accent, neutral, info, success, warning, and error.

Plus the shape and size tokens, with the value the default declares:

Token wayra Used by
--wayra-radius-field 6px controls: Button, the fields, PinCode, Tabs, Dropdown rows, Calendar
--wayra-radius-selector 0.375rem small parts: Badge, Checkbox, Swap, Skeleton, Rating (Radio and Toggle stay absolute)
--wayra-radius-box 10px surfaces: Card, Alert, Modal, Drawer, Popover, Toast, Table, List…
--wayra-border 1px every component
--wayra-size-field 0.22rem the control size ladder (Button, every field, PinCode, Tabs, Dropdown, Table, the label wrapper) and the padding of the surfaces
--wayra-size-selector 0.25rem the selector size ladder (Checkbox, Radio, Toggle, Rating) and the small one-off ladders (Badge, Avatar, Loading, Progress…)
--wayra-font-sans see below typography for every component except Card and Mockup’s body

Sixteen color tokens, seven shape and size tokens. Every one is registered with @property, which makes them type-checked and transitionable. Their initial values also double as the fallback in browsers without light-dark().

The whole wayra palette, transcribed from the :root block that defines it. Each light-dark() is written on one line here; nothing else is changed, and the comments are the source’s own.

:root {
color-scheme: light dark;
/* Window, sidebar and separator grays */
--wayra-color-base-100: light-dark(oklch(100% 0 0), oklch(24% 0.003 250));
--wayra-color-base-200: light-dark(oklch(97% 0.002 250), oklch(30% 0.003 250));
--wayra-color-base-300: light-dark(oklch(92% 0.004 250), oklch(35% 0.003 250));
--wayra-color-base-content: light-dark(oklch(23% 0.004 250), oklch(97% 0.003 250));
/* Blue */
--wayra-color-primary: light-dark(oklch(60% 0.218 257), oklch(62% 0.206 255));
--wayra-color-primary-content: oklch(99% 0 0);
/* Indigo */
--wayra-color-secondary: light-dark(oklch(53% 0.191 278), oklch(56% 0.203 278));
--wayra-color-secondary-content: oklch(99% 0 0);
/* Teal, light enough for the dark label color */
--wayra-color-accent: light-dark(oklch(70% 0.111 213), oklch(77% 0.118 212));
--wayra-color-accent-content: oklch(23% 0.004 250);
/* Neutral grays, swapped between light and dark */
--wayra-color-neutral: light-dark(oklch(35% 0.003 250), oklch(86% 0.007 250));
--wayra-color-neutral-content: light-dark(oklch(97% 0.003 250), oklch(23% 0.004 250));
/* Cyan */
--wayra-color-info: light-dark(oklch(71% 0.133 234), oklch(82% 0.118 228));
--wayra-color-info-content: oklch(23% 0.004 250);
/* Green */
--wayra-color-success: light-dark(oklch(73% 0.194 147), oklch(76% 0.208 147));
--wayra-color-success-content: oklch(23% 0.004 250);
/* Orange */
--wayra-color-warning: light-dark(oklch(77% 0.175 63), oklch(78% 0.171 67));
--wayra-color-warning-content: oklch(23% 0.004 250);
/* Red */
--wayra-color-error: light-dark(oklch(65% 0.232 29), oklch(66% 0.224 28));
--wayra-color-error-content: oklch(99% 0 0);
--wayra-radius-selector: 0.375rem;
--wayra-radius-field: 6px;
--wayra-radius-box: 10px;
--wayra-border: 1px;
--wayra-size-field: 0.22rem;
--wayra-size-selector: 0.25rem;
}

Two things in that block are easy to read past. Seven -content tokens are a single value, not a light-dark() pair — that is measured, not an omission: one label color clears contrast in both schemes for those roles, and for primary and error neither half clears it, so white remains the best available label. And --wayra-font-sans is not there at all: :root never redeclares it, so it resolves to its @property initial value, which is the platform stack: San Francisco on Apple systems, Segoe UI on Windows, Roboto on Android, and system-ui wherever the engine knows the system font by that name.

@property --wayra-font-sans {
syntax: "*";
inherits: true;
initial-value:
-apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto,
"SF Pro Text", "Helvetica Neue", Arial, sans-serif;
}

A theme that wants a different font declares the name like any other token; there is no second mechanism.

Tracking is the one value tuned to that stack. A label on the size ladder — Button, a tab, Calendar’s title, Fab’s actions — takes its tracking from its size, from +0.003em at xs to −0.013em at lg: half of Apple’s published SF Pro table, because Safari and Chrome already apply the full table to the system font on Apple platforms. The steps are private; a theme that brings its own face and wants other tracking sets letter-spacing on the classes in unlayered CSS, the same override as everything else.

Motion, focus-ring geometry and the disabled opacities are custom properties too, but private ones (--_wayra-*). They exist so components stop copying the same literal into five files. They are not a theming surface, and they can change in a patch release.

--wayra-color-<role> does double duty. It is the solid background of a filled control, and it is the text and border of an outline or ghost one. -content is only ever the text that sits on the solid color. There is no third step.

Job Foreground Background Needs
filled <role>-content <role> 4.5:1
outline / ghost <role> base-100 4.5:1 text, 3:1 border

The two jobs pull in opposite directions. Lighten a role and it carries dark -content beautifully while failing as text. Darken it and the reverse happens. The window that satisfies both is roughly oklch(50%–58%) on a white surface, which is where every role in the AA-clearing palettes sits — the default (wayra) trades that window for palette fidelity; see contrast.

A theme works on any element, with one catch that decides how you should write one.

A theme only re-resolves the tokens it declares itself. Anything you leave out was declared at :root, and it resolves its light-dark() there. On a themed subtree it keeps :root’s scheme no matter what your block says. You never notice this on <html>, because there the theme and :root are the same element.

So the rule is: declare every color token, with both halves. All six named themes do, which is why any of them can be dropped on a <section> — or on the preview frames above.

To pin a theme to one appearance, set color-scheme: light (or dark) on the themed element. Its own light-dark() pairs then resolve to that half. To flip the defaults from JavaScript, set it where those tokens are declared:

document.documentElement.style.colorScheme = 'light';

A switcher only has to swap data-theme or color-scheme, but the swap shows a seam: the controls fade their colours over 140ms, the way they do on hover, while the page around them changes at once. Wrap the swap in a view transition and the browser cross-fades the whole page as one. Where the API is missing, or the visitor asked for reduced motion, apply it directly:

function switchTheme(update) {
if (
typeof document.startViewTransition !== 'function' ||
matchMedia('(prefers-reduced-motion: reduce)').matches
) {
update();
return;
}
document.startViewTransition(update);
}
switchTheme(() => {
document.documentElement.dataset.theme = 'terra';
});

The library needs nothing for this, and the theme switchers on these pages do exactly that. A theme restored on load has nothing to fade from, so set it directly.

Every shipped theme clears WCAG AA (4.5:1) on all eight roles, for both jobs, in both schemes — except wayra, the default, and the dark half of selva. The palettes were fitted to that constraint rather than assumed to meet it. All 96 role and scheme combinations of the six named themes were measured: 69 kept the author’s exact value and 27 were darkened or lightened. Hue is never touched, and chroma only when sRGB forces it. Of the combinations that make the promise, the tightest is 4.50:1. Storybook holds each of those theme and scheme pairs to it with axe, including the muted gray on an 8% wash and a pressed filled button. One state is outside the promise for now: the tinted wash an outline or ghost Button takes on hover and press can bring its label under 4.5:1, and the fix is still an open design decision.

selva’s dark half was retuned later, and two of its roles no longer clear the bar. Its light half still clears it everywhere, as do the other six dark roles (the tightest is 5.60:1):

Role filled outline / ghost
neutral 15.54:1 1.14:1
error 3.16:1 5.60:1

neutral is base-200 exactly, so in the dark scheme it is a surface and was never picked to be text on the page. error puts light content on a mid-red fill, the pairing every other role in the palette avoids.

wayra is the other exception. It reproduces a fixed system palette exactly as that palette was published, and some of those colors were never picked to be text:

Role filled outline / ghost
accent 6.57:1 2.57:1
info 6.72:1 2.51:1
success 7.59:1 2.22:1
warning 7.78:1 2.17:1
primary 3.93:1 4.05:1
error 3.50:1 3.61:1

Fidelity to that palette is the point of the theme: it is the one theme that trades a measured ratio for reproducing its source untouched. Pick another one if AA is a requirement.

Disabled controls sit at opacity: 0.4, which drops below 4.5:1 by design. WCAG 1.4.3 exempts inactive controls, and the fade is the signal. Users who ask for prefers-contrast: more get 0.65 instead.