Lunia DS

Actions and inputs

Theme toggle

A light and dark switch where the sun folds its rays and a shadow slides across to make the moon, and the new theme spreads over the page in a circle from the button.

Loading preview

Three toggles side by side on the header bar of a small settings page: icon, pill and segmented, all driving the theme of that small page only (the docs site keeps its own theme). Replay switches Light to Dark and back with the icon button (the reveal runs from the button), then picks System in the segmented control.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add theme-toggle

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { ThemeToggle } from './theme-toggle';
import { resolveTheme, subscribeSystemTheme, systemTheme, type ThemeValue } from './theme-toggle-core';

/** The demo themes its own small page, so it never changes the theme of the site around it. */
export default function ThemeToggleDemo({ replay = 0 }: { replay?: number }) {
  const [theme, setTheme] = React.useState<ThemeValue>('system');
  const system = React.useSyncExternalStore(subscribeSystemTheme, systemTheme, () => 'light' as const);
  const page = React.useRef<HTMLDivElement>(null);
  const resolved = resolveTheme(theme, system);

  React.useEffect(() => {
    if (replay === 0) return;
    const click = (selector: string) => page.current?.querySelector<HTMLElement>(selector)?.click();
    const timers = [
      window.setTimeout(() => click('[data-variant="icon"] button'), 400),
      window.setTimeout(() => click('[data-variant="icon"] button'), 1800),
      window.setTimeout(() => click('[role="radio"][aria-label="System"]'), 3200),
    ];
    return () => {
      for (const timer of timers) window.clearTimeout(timer);
    };
  }, [replay]);

  return (
    <div
      ref={page}
      className={`${resolved} w-[560px] max-w-full overflow-hidden rounded-2xl border border-secondary bg-primary shadow-xs`}
    >
      <div className="flex flex-wrap items-center justify-between gap-lg border-b border-secondary px-xl py-lg">
        <span className="text-sm font-semibold text-primary">Settings</span>
        <div className="flex items-center gap-md">
          <ThemeToggle data-variant="icon" variant="icon" value={theme} onValueChange={setTheme} />
          <ThemeToggle data-variant="pill" variant="pill" value={theme} onValueChange={setTheme} />
          <ThemeToggle data-variant="segmented" variant="segmented" value={theme} onValueChange={setTheme} />
        </div>
      </div>
      <div className="flex flex-col gap-xs px-xl py-2xl">
        <p className="text-sm font-medium text-primary">Appearance</p>
        <p className="text-sm text-tertiary">
          This small page follows the toggles. System uses your device setting.
        </p>
      </div>
    </div>
  );
}

API

Parts: root, button (icon or pill variant), sun and moon icon (disc, shadow mask, eight rays), label (text morph, pill variant), segmented group (radiogroup), segment (radio: sun, system screen, moon), segment thumb, page reveal (view transition circle).

PropTypeDefaultDescription
variant"icon" | "pill" | "segmented""icon"Round icon button, icon with a Light or Dark label, or a Light, System, Dark segmented control.
value"light" | "dark" | "system"undefinedControlled theme, for example from next-themes or @nuxtjs/color-mode.
defaultValue"light" | "dark" | "system""system"Starting theme when uncontrolled.
storageKeystring"theme"Where an uncontrolled toggle remembers the choice (localStorage).
attribute"class" | "data-theme""class"How an uncontrolled toggle marks dark mode on the html element: the dark class or data-theme. Lunia tokens read both.
revealbooleantrueSpread the new theme from the button in a circle where the browser supports View Transitions.
EventDescription
valueChangeFires with the chosen theme.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
TT1hover the buttonbuttonbackground-colortransparent → bg-primary_hoverduration-lg (320)ease-outSwaps at once
TT2press the buttonbuttontransform scale1 → scale-pressduration-2xs (120)ease-exitStays still
TT3Light -> Darksun and moon disctransform scale (sun core grows into the moon)0.55 → 1.05duration-4xl (520)ease-throwSwaps at once
TT4Light -> Darkshadow mask circletransform translate (slides across the disc and carves the crescent)14px, -14px → 5px, -5pxduration-4xl (520)ease-outSwaps at once
TT5Light -> Darkraystransform rotate + scale (fold in and turn away)0deg, 1 → -90deg, 0.4duration-4xl (520)ease-outSwaps at once
TT6Light -> Darkraysopacity1 → 0duration-lg (320)ease-outHides at once
TT7Dark -> Lightdisc, shadow, raysreverse of TT3 to TT6 (the shadow slides off, rays open and turn back)moon → sunduration-4xl (520)ease-outSwaps at once
TT8theme changes (pill variant)labeltext morph"Light" → "Dark"duration-3xl (460)ease-outSwaps at once
TT9pick a segment (segmented variant)segment thumbtransform translateXold segment → chosen segmentduration-3xl (460)ease-throwMoves at once
TT10pick a segment (segmented variant)segment thumbwidthold segment width → chosen segment widthduration-xl (380)ease-outResizes at once
TT11pick a segment, or hover onesegment iconcolorfg-tertiary → fg-primaryduration-lg (320)ease-outSwaps at once
TT12the resolved theme changes and reveal is onpage reveal (new page snapshot)clip-path circle (grows from the button center to the farthest page corner)circle 0px → circle radius to the far cornerduration-5xl (640)ease-glideSwaps at once

Accessibility

  • Icon variant: a toggle button (aria-pressed true in dark) named "Switch to dark theme" or "Switch to light theme".
  • Pill variant: the name starts with the visible word so voice control can say it: "Light theme, switch to dark".
  • Segmented variant: a radiogroup named "Theme" with three radios (Light, System, Dark) and one tab stop; Arrow Left and Right move and choose (wrapping), Home and End go to the ends.
  • The page reveal runs only when the colors really change (System to Light on a light system does not reveal).
  • Without View Transitions support, or with reduced motion, the theme switches with all CSS transitions held for one frame, so colors do not animate one by one.
  • Buttons are 36px tall (icon button 36 by 36px); segments are 32 by 40px; above the 24px minimum target.
  • The first paint never animates: the icon starts in the stored theme.
  • Reduced motion: no reveal circle; the icon, thumb and label swap at once.