NS Design System
v3.0.0 · 257 tokens

60Theme toggle

Two forms of one control — a quiet square and a bordered one, both a sun that morphs into a moon — plus the segmented Light · Auto · Dark. All flip the theme through window.nsTheme so the Ghost theme and the app can never disagree about what dark means. The segmented form offers Light · Auto · Dark, and Auto is the default — a two-state toggle silently converts every visitor into someone with an explicit preference, after which their OS switching at sunset does nothing.

Use it for

  • The switch in the navbar actions cluster — the default
  • The segmented form where Auto matters: settings pages, and the mobile sheet
  • The icon square in a bar with nothing left to give — the course bar on a phone

Not for

  • Deciding the initial theme in the component — assets/js/theme-init.js sets it before first paint, and doing it after hydration IS the white flash everyone complains about
  • An unlabelled icon button — aria-label or it is a mystery control
  • Animating the whole page on switch: the swap is instant, only the mark morphs

Switch

Live — it drives this page, and the default in a bar. ONE button, and the sun morphs into the moon in place.

It used to be a knob sliding along a track. Two problems, and the second is the one that mattered: the knob carried ph-sun / ph-moon, neither of which is in the icon subset, so it was empty in both states — a switch with no indicator at all. And a sliding track is the wrong metaphor anyway; it says "setting with two positions" when what the reader wants to know is which mode they are in and how to leave it. A sun that becomes a moon says both in one shape.

Drawn, not set: one disc; eight tapered rays from a repeating conic gradient masked to an annulus; and a second disc the colour of the surface sliding across to bite the crescent. The rays were four box-shadows to begin with, which are round dots — a sun drawn with dots reads as a loading spinner. Going dark, the rays turn as they retract, the disc tips and grows, and the crescent closes: one movement, one element, no font. The state is read from data-theme in CSS, so it is right in the first painted frame.

markup
<button type="button" class="ns-themeswitch" role="switch" aria-checked="false" aria-label="Dark mode" data-ns-theme-toggle>
  <span class="ns-themeswitch__mark" aria-hidden="true"></span>
</button>

Segmented

Live — it drives this page. Mono words rather than glyphs: every product draws the "auto" icon differently and none of them is read correctly, while a mono label is this system's own material. The thumb is a pseudo-element positioned by :has() reading aria-checked, so there is no state class and no JavaScript in the animation.

markup
<div class="ns-themetoggle" role="radiogroup" aria-label="Colour theme" data-ns-theme>
  <button type="button" class="ns-themetoggle__opt" role="radio" aria-checked="false" aria-label="Light" data-ns-theme-value="light">Light</button>
  <button type="button" class="ns-themetoggle__opt" role="radio" aria-checked="true" aria-label="Match the system setting" data-ns-theme-value="system">Auto</button>
  <button type="button" class="ns-themetoggle__opt" role="radio" aria-checked="false" aria-label="Dark" data-ns-theme-value="dark">Dark</button>
</div>

Icon switch

Live. The same mark and the same morph, in a bordered square — for a bar where the control needs to read as a button among buttons rather than as a quiet affordance.

markup
<button type="button" class="ns-themetoggle-icon" role="switch" aria-checked="false" aria-label="Dark mode" data-ns-theme-toggle>
  <span class="ns-themeswitch__mark" aria-hidden="true"></span>
</button>

The mark, enlarged

The indicator on its own, enlarged. Click either control above and watch it.

The sun and the moon are drawn, not set: the icon font is a subset and carries neither, so the glyph pair this used to be rendered as two empty elements — a control with no indicator in either state. One disc; eight tapered rays from a conic gradient masked to a ring; a second disc the colour of the surface sliding across to bite the crescent. The rays turn as they retract, so the change is a rotation rather than a fade, and it costs no font at all. prefers-reduced-motion drops it to instant.

The mark on its own, enlarged. Flip the page theme and the rays retract, the disc turns and the crescent closes — one movement, no glyph swap.
markup
<span style="display:inline-flex;align-items:center;gap:var(--space-6)">
  <span class="ns-themeswitch__mark" style="inline-size:var(--space-6);block-size:var(--space-6)"></span>
  <span style="font-size:var(--size-small);color:var(--color-muted);max-inline-size:22rem">The mark on its own, enlarged. Flip the page theme and the rays retract, the disc turns and the crescent closes — one movement, no glyph swap.</span>
</span>

On a navy bar

Both forms pick up the on-dark inks from the bar, so neither needs a variant of its own.

markup
<nav class="ns-topnav ns-topnav--dark" aria-label="Theme on dark example">
  <a class="ns-topnav__brand" href="#"><img src="../assets/logo/favicon.svg" alt=""><span class="ns-topnav__brand-name">Namaste Salesforce</span></a>
  <div class="ns-topnav__actions">
    <div class="ns-themetoggle" role="radiogroup" aria-label="Colour theme">
      <button type="button" class="ns-themetoggle__opt" role="radio" aria-checked="false" aria-label="Light">Light</button>
      <button type="button" class="ns-themetoggle__opt" role="radio" aria-checked="true" aria-label="Match the system setting">Auto</button>
      <button type="button" class="ns-themetoggle__opt" role="radio" aria-checked="false" aria-label="Dark">Dark</button>
    </div>
    <button type="button" class="ns-themetoggle-icon" role="switch" aria-checked="false" aria-label="Dark mode">
      <i class="ph ph-sun" aria-hidden="true"></i><i class="ph ph-moon" aria-hidden="true"></i>
    </button>
  </div>
</nav>

Accessibility contract

  • Segmented is a real radiogroup: one tab stop, arrow keys inside, aria-checked on the selected option
  • The icon form is role="switch" with aria-checked and an accessible name
  • The state is decided in CSS from data-theme, so it is correct in the first painted frame with no JavaScript having run
  • prefers-reduced-motion drops the morph to instant