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.
Live preview
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-toggleUsage
The demo above, as code.
'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).
| Prop | Type | Default | Description |
|---|---|---|---|
| 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" | undefined | Controlled theme, for example from next-themes or @nuxtjs/color-mode. |
| defaultValue | "light" | "dark" | "system" | "system" | Starting theme when uncontrolled. |
| storageKey | string | "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. |
| reveal | boolean | true | Spread the new theme from the button in a circle where the browser supports View Transitions. |
| Event | Description |
|---|---|
| valueChange | Fires 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TT1 | hover the button | button | background-color | transparent → bg-primary_hover | duration-lg (320)ease-out | Swaps at once |
| TT2 | press the button | button | transform scale | 1 → scale-press | duration-2xs (120)ease-exit | Stays still |
| TT3 | Light -> Dark | sun and moon disc | transform scale (sun core grows into the moon) | 0.55 → 1.05 | duration-4xl (520)ease-throw | Swaps at once |
| TT4 | Light -> Dark | shadow mask circle | transform translate (slides across the disc and carves the crescent) | 14px, -14px → 5px, -5px | duration-4xl (520)ease-out | Swaps at once |
| TT5 | Light -> Dark | rays | transform rotate + scale (fold in and turn away) | 0deg, 1 → -90deg, 0.4 | duration-4xl (520)ease-out | Swaps at once |
| TT6 | Light -> Dark | rays | opacity | 1 → 0 | duration-lg (320)ease-out | Hides at once |
| TT7 | Dark -> Light | disc, shadow, rays | reverse of TT3 to TT6 (the shadow slides off, rays open and turn back) | moon → sun | duration-4xl (520)ease-out | Swaps at once |
| TT8 | theme changes (pill variant) | label | text morph | "Light" → "Dark" | duration-3xl (460)ease-out | Swaps at once |
| TT9 | pick a segment (segmented variant) | segment thumb | transform translateX | old segment → chosen segment | duration-3xl (460)ease-throw | Moves at once |
| TT10 | pick a segment (segmented variant) | segment thumb | width | old segment width → chosen segment width | duration-xl (380)ease-out | Resizes at once |
| TT11 | pick a segment, or hover one | segment icon | color | fg-tertiary → fg-primary | duration-lg (320)ease-out | Swaps at once |
| TT12 | the resolved theme changes and reveal is on | page reveal (new page snapshot) | clip-path circle (grows from the button center to the farthest page corner) | circle 0px → circle radius to the far corner | duration-5xl (640)ease-glide | Swaps 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.