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.
The shipped palettes
Section titled “The shipped palettes”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.
Default — wayra
Section titled “Default — wayra”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
Section titled “aurora”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
Section titled “brutal”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
Section titled “sunset”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.
The tokens
Section titled “The tokens”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().
Every value the default declares
Section titled “Every value the default declares”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.
Every role does two jobs
Section titled “Every role does two jobs”--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.
Themes on a subtree
Section titled “Themes on a subtree”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';Switching at runtime
Section titled “Switching at runtime”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.
Contrast
Section titled “Contrast”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.