Skip to content

Component

theme

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and theme.css. No scripts.

Source

Copy both files, or run npx nojsui add theme.

Source for theme
<!-- .sk-theme — a section that owns its palette.

     Three attributes, each optional, each doing one thing:
       data-theme="light|dark"  pins the colour scheme for everything inside
       data-accent="<color>"    moves the accent here; the text on it follows
       data-tone="inverted"     surface and page swap roles

     Anything that reads the kit's tokens — a button, a link, another
     component — takes the section's palette without knowing it is in one.
     The __panel inside asks the section which theme it is in and adjusts its
     own hairline; nothing selects on the attribute from outside.

     The grid below is demo layout only. -->
<div class="sk-theme__grid">
  <section class="sk-theme" aria-labelledby="theme-page">
    <h2 class="sk-theme__title" id="theme-page">The page's own palette</h2>
    <p class="sk-theme__copy">No attributes. This section takes whatever scheme and accent the page has.</p>
    <button type="button" class="sk-theme__action">Accent</button>
    <div class="sk-theme__panel">
      <code class="sk-theme__code">.sk-theme</code>
    </div>
  </section>

  <section class="sk-theme" data-theme="dark" aria-labelledby="theme-dark">
    <h2 class="sk-theme__title" id="theme-dark">Pinned dark</h2>
    <p class="sk-theme__copy">One attribute flips every <code>light-dark()</code> token inside. The panel's hairline knows, and takes the strong rule.</p>
    <button type="button" class="sk-theme__action">Accent</button>
    <div class="sk-theme__panel">
      <code class="sk-theme__code">data-theme="dark"</code>
    </div>
  </section>

  <section class="sk-theme" data-accent="oklch(88% 0.17 95)" aria-labelledby="theme-accent">
    <h2 class="sk-theme__title" id="theme-accent">A pale accent</h2>
    <p class="sk-theme__copy">The colour is written in the markup. The text on it is derived — black here, because <code>contrast-color()</code> looked at this accent, not the page's.</p>
    <button type="button" class="sk-theme__action">Accent</button>
    <div class="sk-theme__panel">
      <code class="sk-theme__code">data-accent="oklch(88% 0.17 95)"</code>
    </div>
  </section>

  <section class="sk-theme" data-tone="inverted" aria-labelledby="theme-inverted">
    <h2 class="sk-theme__title" id="theme-inverted">Inverted</h2>
    <p class="sk-theme__copy">Surface and page swap roles, in either scheme. A callout band that reads as one, and still the same tokens.</p>
    <button type="button" class="sk-theme__action">Accent</button>
    <div class="sk-theme__panel">
      <code class="sk-theme__code">data-tone="inverted"</code>
    </div>
  </section>
</div>
/* .sk-theme — a section that owns its palette.

   The kit's colours are light-dark() pairs, so a theme is not a second
   palette: it is a scope that pins color-scheme, or moves the accent, and lets
   the cascade do the rest. tokens.css already reads [data-theme="light|dark"]
   and re-resolves the contrast-color() pairs inside any [data-theme] scope
   (ADR 0022). This component is the copyable shape of that: one element, three
   attributes, and a child that reacts to the theme by name.

     data-theme="light|dark"   pin the scheme for this subtree
     data-accent="<color>"     move the accent here; on-accent follows it
     data-tone="inverted"      surface and page swap roles, for a callout band

   The accent comes in through typed attr(), which is the whole point: the
   colour is authored where the section is, in HTML, and the readable
   foreground is derived, not chosen. Without typed attr() the section keeps
   the page's accent, which is a working section rather than a broken one.

   Specs:
   - light-dark() ............ https://drafts.csswg.org/css-color-5/#light-dark
   - contrast-color() ........ https://drafts.csswg.org/css-color-5/#contrast-color
   - color-scheme ............ https://drafts.csswg.org/css-color-adjust-1/#color-scheme-prop
   - typed attr() ............ https://drafts.csswg.org/css-values-5/#attr-notation
   - container style queries . https://drafts.csswg.org/css-contain-3/#style-container */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-theme {
  --_bg: var(--sk-theme-bg, var(--sk-color-surface));
  --_radius: var(--sk-theme-radius, var(--sk-radius-lg));
  --_padding: var(--sk-theme-padding, var(--sk-space-xl));

  /* Published for children: `@container style(--sk-theme: dark)` matches a
     descendant of a dark section with no selector chain (see __panel). Set
     from the attribute so it can never disagree with data-theme. */
  --sk-theme: light;

  padding: var(--_padding);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--sk-color-text);
}

/* color-scheme itself comes from tokens.css §4, which reads [data-theme] on
   any element. Only the published name lives here. */
.sk-theme[data-theme="dark"] {
  --sk-theme: dark;
}

/* Surface and page swap roles. Both are scheme-aware tokens, so this holds
   in either scheme, and check-contrast.mjs holds text against both. */
.sk-theme[data-tone="inverted"] {
  --_bg: var(--sk-theme-bg, var(--sk-color-text));
  --sk-theme: inverted;

  color: var(--sk-color-bg);
}

/* The accent, from the attribute. Declared on the scope, so every component
   inside that reads --sk-color-accent — a button, a link, a badge — takes it.
   The attr() fallback cannot be the page's accent: `var(--sk-color-accent)`
   inside its own declaration is a cycle. So a value that does not parse as
   a colour falls to the text colour — a section whose accent is visibly the
   wrong one, rather than one whose buttons have no background at all. */
@supports (color: attr(data-accent type(<color>), red)) {
  .sk-theme[data-accent] {
    --sk-color-accent: attr(data-accent type(<color>), var(--sk-color-text));
  }
}

/* The readable foreground on that accent. contrast-color() picks black or
   white from the colour actually in force HERE — declared on the scope, not
   inherited from :root, or it would be the answer for the page's accent
   (ADR 0022). That is what makes a pale accent get dark text without anyone
   choosing it. Without contrast-color() the scope inherits the pair
   tokens.css ships, which is right for the default accent in both schemes;
   the README says what to do for a pale custom accent there. */
@supports (color: contrast-color(red)) {
  .sk-theme[data-accent] {
    --sk-color-on-accent: contrast-color(var(--sk-color-accent));
  }
}

/* ---------------------------------------------------------------------------
   A child that reacts to the theme by name.

   __panel is a box inside the section. On the surface its hairline is the
   kit's border colour; on a dark section that hairline disappears into the
   surface, so it asks its container which theme it is in and takes the strong
   rule instead. 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.
   Without style queries it keeps the default hairline — dimmer, not gone.
--------------------------------------------------------------------------- */

.sk-theme__panel {
  margin-block-start: var(--sk-space-md);
  padding: var(--sk-space-md);
  border: var(--sk-border-width) solid var(--sk-color-border);
  border-radius: var(--sk-radius-md);
}

@container style(--sk-theme: dark) {
  .sk-theme__panel {
    border-color: var(--sk-color-border-strong);
  }
}

/* An inverted band is dark on a light page, and its hairline needs the same
   help. Two queries rather than one selector list: `style()` takes a single
   declaration, so the rule is repeated rather than the selector chained. */
@container style(--sk-theme: inverted) {
  .sk-theme__panel {
    border-color: var(--sk-color-bg);
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   Demo copy only. Delete when copying .sk-theme into your own page.
--------------------------------------------------------------------------- */

.sk-theme__grid {
  display: grid;
  gap: var(--sk-space-lg);
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}

.sk-theme__title {
  margin: 0;
  font-size: var(--sk-text-md);
  font-weight: var(--sk-weight-semibold);
}

.sk-theme__copy {
  margin-block: var(--sk-space-xs) var(--sk-space-md);
  color: inherit;
}

/* A native button on the scope's accent, so the demo shows the derived
   foreground doing its job. Not the kit's button component (rule 7: no
   cross-component imports) — the minimum that makes the accent visible. */
.sk-theme__action {
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  border: 0;
  border-radius: var(--sk-radius-md);
  background: var(--sk-color-accent);
  color: var(--sk-color-on-accent);
  font: inherit;
  font-weight: var(--sk-weight-medium);
}

.sk-theme__code {
  font-family: var(--sk-font-mono);
  font-size: var(--sk-text-xs);
}

Browser support

Baseline newly available

Shipped in every major engine, but only recently — older versions need the fallback.

Per-feature support, generated from web-features 3.39.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
light-dark()newly12312312017.512312017.5
color-schemewidely98989613989613
Container style queriesnewly1111111511811115118
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
attr()1 of its partslimited133133155—133155—
contrast-color()newly1471471462614714626

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.

AttributeValuesEffect
data-themelight, darkPins color-scheme for the subtree; every light-dark() token flips
data-accentany <color>Sets --sk-color-accent for the subtree; --sk-color-on-accent is derived from it
data-toneinvertedSurface 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.

KeyBehaviour
TabPasses 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 a div.
  • data-theme="dark" inside a light page is still one page. Contrast holds because every pair in tokens.css is 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-accent on the same section by hand in that case; the section reads it like any other token.

Degradation

Feature usedBaseline statusBehavior 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-schemewidelydata-theme has no effect; the section renders in the page’s scheme.
Container style queriesnewly (Chrome 111+, Firefox 151+, Safari 18+)__panel keeps the default hairline on a dark or inverted section — dimmer, not gone.
Typed attr() limiteddata-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

PropertyDefaultControls
--sk-theme-bg--sk-color-surface (--sk-color-text when inverted)The section’s background
--sk-theme-radius--sk-radius-lgCorner radius of the section
--sk-theme-padding--sk-space-xlPadding on all sides