Table of contents
- Theming, and the system theme
Theming, and the system theme
driftwood components never carry a colour of their own. They write semantic
utilities — bg-card, text-muted-foreground, border-input, ring-ring —
which theme.css maps onto CSS custom properties your app defines. That is why
one token block re-themes the whole kit, and why no component contains a dark:
variant or a hex value.
Three stylesheets, imported in this order:
@import 'tailwindcss';
@import 'tw-animate-css'; /* Dialog + Tooltip entrances */
@import '@l4st/driftwood/base.css'; /* element defaults, reduced-motion */
@import '@l4st/driftwood/tokens.css'; /* the default palette — or write your own */
@import '@l4st/driftwood/theme.css'; /* tokens -> utilities. Always last. */
theme.css last is the only ordering that matters: its @theme inline block
has to see the token names to bind them to utilities.
One composition, rendered once. The right half is the identical DOM inside
color-scheme: dark — every colour you see differ is a light-dark() token
resolving the other way.
How the system theme works
tokens.css ships one palette that serves both themes, and lets the browser
pick the branch:
:root {
color-scheme: light dark;
--card: light-dark(oklch(1 0 0), oklch(0.205 0.008 var(--hue)));
/* … every token, same shape */
}
Two mechanisms, and both are load-bearing:
light-dark(light, dark)resolves to its first or second argument depending on the element's usedcolor-scheme. Light and dark values live on the same line, so a token cannot be changed in one theme and forgotten in the other — which is exactly what happens with two duplicated blocks.color-scheme: light darkdeclares that the page supports both and, with no override, follows the OS. It is also the part aprefers-color-schememedia query never gave you: it themes the browser's own furniture — scrollbars, native form controls, autofill backgrounds, the text caret, spellcheck underlines — which otherwise stays stubbornly light on a dark page.
Nothing else is wired: no class on <html>, no JavaScript, no flash-of-wrong-
theme script. Changing the OS appearance repaints the app live.
Dark-mode extensions
Some dark-mode extensions (Noir, for one) decide whether a site already has a
dark theme by scanning its stylesheets for a prefers-color-scheme media
query. They do not recognise light-dark(), so a palette built only from it
looks light-only to them, and they repaint the app on top of its own dark theme:
dark text on a dark card, inverted chart colours, surfaces dimmed twice.
So tokens.css carries one rule whose job is to be found:
@media (prefers-color-scheme: dark) {
:root { color-scheme: light dark; }
}
It restates the :root declaration with the same selector, so it changes
nothing and every pin still wins. A production build may emit a similar query on
its own while polyfilling light-dark() for older targets, but the dev server
and modern targets do not, which is why it is written out rather than left to
the bundler. If you write your own palette, copy this rule too.
--hue drives the brand and the neutral ramp together, so the greys carry a
whisper of the accent instead of reading as flat #808080. Change that one
number and surfaces, borders, primary and ring all move — and so do chart
slots 1 and 4, which are the brand hue at two lightnesses. Status colours
(destructive, success, warning) and chart slots 2, 3 and 5 keep their own hues on
purpose — a red that follows the brand hue is no longer a red.
The five --chart-* slots are a categorical ramp: a series takes the slot at
its index and keeps it, so a filter that removes one series never repaints the
others. Neighbouring slots are the pair a reader actually compares — two
segments of a stack, two rows of a legend — so each adjacent pair is checked to
clear ΔE 8 under simulated protanopia and deuteranopia and ΔE 15 under normal
vision. That is also why slots 1 and 4 share the brand hue at different
lightness: colour blindness collapses hue, and lightness is the separation that
survives it. If you write your own palette, keep that property rather than
picking five colours you like the look of, and pair the ramp with the legend and
direct labels the chart components already render — colour is never the only
channel. Past five series the ramp stops: group the tail into an "Other" rather
than inventing a sixth hue.
Browser support: light-dark() needs Chrome 123 / Safari 17.5 / Firefox 120.
Below that, write your palette as two blocks instead (see below); the components
do not care how the tokens are produced.
Using it
Follow the system (the default, zero config)
@import '@l4st/driftwood/tokens.css';
Add this to app.html so the very first paint is right, before any CSS lands:
<meta name="color-scheme" content="light dark" />
Your own palette, still following the system
Copy the token list out of tokens.css, change the values, keep the shape.
Define every token — components reference them unconditionally — and keep
the prefers-color-scheme rule from
above, or dark-mode extensions will
repaint your dark theme.
:root {
color-scheme: light dark;
--hue: 35; /* amber */
--background: light-dark(oklch(0.99 0.01 var(--hue)), oklch(0.16 0.01 var(--hue)));
--card: light-dark(oklch(1 0 0), oklch(0.21 0.01 var(--hue)));
/* … */
--radius: 0.625rem;
}
Pin a theme, or offer a switcher
Pinning is one line per theme — flip color-scheme and every light-dark()
token follows on its own. No palette duplication:
:root.dark, :root[data-theme='dark'] { color-scheme: dark; }
:root.light, :root[data-theme='light'] { color-scheme: light; }
Both selectors already ship in tokens.css, so a class-based switcher such as
mode-watcher works alongside
the system default with nothing added: it writes .dark on <html>, the
color-scheme flips, the tokens follow. Leave the class off and you are back on
the OS.
A three-way switcher (light / dark / system) is the same idea: write
data-theme="light" or "dark" to pin, and remove the attribute for
system — do not write data-theme="system", because absence is what hands the
decision back to color-scheme: light dark.
<script lang="ts">
import { Select } from '@l4st/driftwood';
let mode = $state<'system' | 'light' | 'dark'>('system');
$effect(() => {
const root = document.documentElement;
if (mode === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', mode);
localStorage.setItem('theme', mode);
});
</script>
Older browsers, or a palette you already have
Two blocks work fine — the kit reads tokens, not how they were produced:
:root { --card: #fff; /* … */ }
@media (prefers-color-scheme: dark) {
:root { --card: #1a1a1a; /* … */ }
}
Still declare color-scheme (light dark, or the pinned value) so browser
furniture matches.
What is verified
pnpm probe drives a real headless Chromium with the OS theme emulated both
ways. It also runs axe over the gallery and the overlays, which makes token
contrast a build failure rather than a judgement call — if a token pair falls
below 4.5:1 behind small text, the probes go red. It asserts:
color-schemeresolves tolight darkunder both OS settings;- the served stylesheets contain a
prefers-color-scheme: darkmedia rule, found the way an extension finds it: by walkingdocument.styleSheets; - surfaces, text and the body background all differ between the two;
- opacity modifiers (
bg-primary/90) resolve per theme — Tailwind wraps the token incolor-mix(), which is the caselight-dark()could plausibly have broken; .lightpins light under a dark OS, anddata-theme="dark"pins dark under a light OS.
pnpm shots renders every component twice, with colorScheme emulated, which
is where the light/dark pair on each component page comes from.
Checking your own palette
The components are only as accessible as the tokens behind them, and a palette is easy to get wrong in a way nobody sees until someone reports it. From a driftwood checkout:
pnpm contrast path/to/your/app.css
It reads the token block, splits light-dark() into both themes, and computes
every pair the components actually render — body text, a Card description,
muted text on bg-muted, each Badge's label on its status colour, the Alert
tints, the sidebar pairs, and the hover blends — failing anything below 4.5:1.
No browser, no gallery, no guessing which pairs matter.
The script ships with the package (node_modules/@l4st/driftwood/scripts/contrast.mjs),
so an app can run it in its own CI.
Rules for anyone writing components
- Semantic tokens only:
bg-card, notbg-white;text-muted-foreground, nottext-gray-500;border-input, notborder-zinc-200. - No
dark:variants in the kit. Adark:assumes a class-based strategy and silently breaks the app that follows the system or uses a media query. - New token? Add it to
tokens.css(inlight-dark()form), map it intheme.css, and say so in the release notes — every consuming app has to add it to its own palette.
driftwood
Layout
Actions
Forms
- Field
- Input
- FileInput
- FileList
- Textarea
- CodeEditor
- Combobox
- RadioGroup
- NumberInput
- InlineEdit
- Label
- Select
- MultiSelect
- DatePicker
- TagsInput
- Checkbox
- Switch
- Slider
Feedback
Charts
Overlays
Navigation
Generated from the source by pnpm wiki · repository · v0.24.0
