Component
theme
Demo
tokens.css,
base.css and theme.css. No scripts.
Source
Copy both files, or run npx nojsui add theme.
Browser support
Shipped in every major engine, but only recently — older versions need the fallback.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| light-dark() | newly | 123 | 123 | 120 | 17.5 | 123 | 120 | 17.5 |
| color-scheme | widely | 98 | 98 | 96 | 13 | 98 | 96 | 13 |
| Container style queries | newly | 111 | 111 | 151 | 18 | 111 | 151 | 18 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| attr()1 of its parts | limited | 133 | 133 | 155 | — | 133 | 155 | — |
| contrast-color() | newly | 147 | 147 | 146 | 26 | 147 | 146 | 26 |
Usage
<section class="sk-theme" data-theme="dark" aria-labelledby="signup-title">
<h2 id="signup-title">Sign up</h2>
<p>Everything in here is in the dark scheme — buttons, links, other components.</p>
<button type="button">Get started</button>
</section>
<section class="sk-theme" data-accent="oklch(88% 0.17 95)" aria-labelledby="promo-title">
<h2 id="promo-title">Summer</h2>
<p>The accent is authored here, and text on it is readable without picking a colour.</p>
</section>
One element and three optional attributes. Anything inside that reads the kit’s
tokens — a button, a link, another component — takes the section’s palette
without knowing it is in one. There is nothing to wire up and nothing to keep in
sync: a theme is a scope, not a second palette.
| Attribute | Values | Effect |
|---|---|---|
data-theme | light, dark | Pins color-scheme for the subtree; every light-dark() token flips |
data-accent | any <color> | Sets --sk-color-accent for the subtree; --sk-color-on-accent is derived from it |
data-tone | inverted | Surface and page swap roles, in either scheme |
How it works
A scheme pin is one property. The kit’s colours are light-dark() pairs,
so data-theme="dark" only has to set color-scheme: dark on the section —
tokens.css §4 already does that for any [data-theme] — and the cascade does
the rest. Nothing here declares a dark palette.
The accent comes in through typed attr(). data-accent is read as a
<color> and declared as --sk-color-accent on the section, so it is the
accent for everything inside. The colour is authored where the section is, in
HTML, and there is no class to invent for each one.
The text on it is derived. --sk-color-on-accent is redeclared on the
section as contrast-color(var(--sk-color-accent)). Redeclared, not inherited:
a custom property resolves where it is declared, so the root’s on-accent was
computed against the root’s accent and would stay white on a pale yellow. The
demo’s third section is exactly that case — ADR 0022 has the measurements — and
it gets black text without anyone choosing it.
The panel asks, it is not told. .sk-theme publishes --sk-theme (light,
dark or inverted), and .sk-theme__panel reads it with
@container style(--sk-theme: dark) to take the strong hairline a dark surface
needs. No .sk-theme[data-theme="dark"] .sk-theme__panel chain: the panel does
not need to know how the theme was set, only what it is. That is the per-section
recipe — a child that reacts to its section’s theme by name — and any component
of yours can do the same with the same query.
Why not data-theme for the accent too? Because a pinned scheme and a moved
accent are independent: a dark section with the page’s accent, a light section
with a pale one, or both. Three attributes compose; one enum would not.
Keyboard contract
None. The section is not interactive; the demo’s button is a native <button>
placed there to show the accent, and behaves as one.
| Key | Behaviour |
|---|---|
Tab | Passes through to whatever you put inside |
Verified manually in VoiceOver and NVDA: not yet — the section adds no semantics of its own, but the demo’s landmarks and headings should be checked before the component is marked done.
Accessibility notes
- Use a landmark or a heading. The demo uses
<section aria-labelledby>; a section with a name is a region a screen reader can jump to, one without is just adiv. data-theme="dark"inside a light page is still one page. Contrast holds because every pair intokens.cssis checked in both schemes (check-contrast.mjs), and the section only picks which side of each pair applies.- A custom accent is your colour.
contrast-color()guarantees the text on it is black or white, whichever reads; it does not guarantee the accent itself contrasts with the surface around it. A pale accent on a white page is a pale button — check that pairing yourself. - Without
contrast-color()(see Degradation), a pale custom accent gets the default light-on-accent text. Set--sk-color-on-accenton the same section by hand in that case; the section reads it like any other token.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
light-dark() | newly (Chrome 123+, Firefox 120+, Safari 17.5+) | Nothing in this component — tokens.css handles the fallback (ADR 0005). Listed because a scheme pin flips nothing without it. |
color-scheme | widely | data-theme has no effect; the section renders in the page’s scheme. |
| Container style queries | newly (Chrome 111+, Firefox 151+, Safari 18+) | __panel keeps the default hairline on a dark or inverted section — dimmer, not gone. |
Typed attr() | limited | data-accent is ignored; the section keeps the page’s accent. A working section, not a broken one. |
contrast-color() | newly (Chrome 147+, Firefox 146+, Safari 26+) | Text on a custom accent is the pair tokens.css ships — right for the default accent in both schemes, wrong for a pale one. Set --sk-color-on-accent on the section by hand. |
Theming
| Property | Default | Controls |
|---|---|---|
--sk-theme-bg | --sk-color-surface (--sk-color-text when inverted) | The section’s background |
--sk-theme-radius | --sk-radius-lg | Corner radius of the section |
--sk-theme-padding | --sk-space-xl | Padding on all sides |