Dark mode
StableOne class flips every token: on <html> for the whole page, or on any wrapper for one part of it.
Dark mode is class-based. A script in the page’s <head> adds .dark to <html> before the first paint (the saved choice, else the system setting), so there is never a flash of the wrong theme. Tokens are CSS variables that .dark redefines, and the dark: variant matches anything inside a .dark ancestor, so a single wrapper can keep a panel dark on a light page.
Theme switch
import { Segmented } from '@rchursin/product/ui'import { useTheme, type ThemeChoice } from '@rchursin/product/lib/theme'
/** * The site's own theme switch, drawn with the Library's Segmented control. It changes the whole * page: the choice is saved, "System" follows the operating system, and the switch cross-fades. */export default function ThemeSwitch() { const { choice, resolved, setChoice } = useTheme() return ( <div className="flex w-full max-w-sm flex-col items-center gap-6"> <Segmented<ThemeChoice> label="Theme" value={choice} onChange={setChoice} options={[ { value: 'system', label: 'System', icon: 'monitor' }, { value: 'light', label: 'Light', icon: 'sun' }, { value: 'dark', label: 'Dark', icon: 'moon' }, ]} /> <dl className="grid w-full grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-2 rounded-xl bg-sunken px-4 py-3 text-[12.5px] ring-1 ring-line"> <dt className="text-fg-3">Saved choice</dt> <dd className="text-right font-mono text-fg">{choice}</dd> <dt className="text-fg-3">Showing</dt> <dd className="text-right font-mono text-fg">{resolved}</dd> <dt className="text-fg-3">On <html></dt> <dd className="text-right font-mono text-fg">{resolved === 'dark' ? 'class="dark"' : 'no class'}</dd> </dl> </div> )}Usage
import '@rchursin/product.css' // brand tokens, fonts, the finishing layer and the app tokensimport { useTheme } from '@rchursin/product/lib/theme' // the theme store (saved per viewer)<!-- index.html, in <head>: set the theme before the first paint --><script> ;(function () { try { var saved = JSON.parse(localStorage.getItem('rc-kit:v1:theme') || 'null') } catch (e) {} var dark = saved === 'dark' || (saved !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches) var root = document.documentElement root.classList.toggle('dark', dark) root.style.colorScheme = dark ? 'dark' : 'light' root.dataset.theme = dark ? 'dark' : 'light' })()</script>
// Anywhere: every token inside a .dark wrapper flips<aside className="dark rounded-2xl bg-surface p-6 text-fg">…</aside>Examples
One part, always dark
class="dark" on a wrapper flips surface, text, lines and buttons inside it. The sign-in layout’s brand panel works this way.
import { Button } from '@rchursin/product/ui'
/** * `dark` on any wrapper flips every token inside it, whatever the page is doing. The sign-in * layout uses this for its brand panel. dark: utilities follow the wrapper too, because the * variant matches any element inside a .dark ancestor. */function Panel({ label, title, body, action }: { label: string; title: string; body: string; action: string }) { return ( <div className="flex h-full flex-col gap-4 rounded-2xl bg-surface p-5 text-fg ring-1 ring-line"> <p className="font-mono text-[11px] font-medium tracking-[0.12em] text-stone uppercase dark:text-fog">{label}</p> <div className="flex flex-col gap-1.5"> <h3 className="text-[17px] font-semibold tracking-[-0.015em]">{title}</h3> <p className="text-[13px]/5 text-pretty text-fg-3">{body}</p> </div> <div className="mt-auto flex flex-wrap items-center gap-2 border-t border-line pt-4"> <Button size="sm">{action}</Button> <Button size="sm" variant="ghost"> Not now </Button> </div> </div> )}
export default function DarkSubtree() { return ( <div className="@container w-full max-w-2xl"> <div className="grid gap-4 @lg:grid-cols-2"> <Panel label="Follows the page" title="Weekly summary" body="Light or dark, whichever the visitor picked." action="Send it" /> <div className="dark"> <Panel label="Always dark" title="Brand panel" body="The wrapper has class dark, so surface, text, lines and buttons all switch." action="Continue" /> </div> </div> </div> )}API
useTheme() returns
| Prop | Type | Default | Description |
|---|---|---|---|
choice | 'system' | 'light' | 'dark' | – | What the visitor picked. System is the default; choosing it clears the saved choice. |
resolved | 'light' | 'dark' | – | What is showing now. Follows the operating system while choice is system. |
setChoice | (next: ThemeChoice) => void | – | Saves the choice (in localStorage, shared with the product kit), applies it to <html> and cross-fades with a view transition when motion is allowed. |
CSS
| Class or variable | What it does |
|---|---|
.dark | Redefines every --rc-* and --lux-* token for its subtree, and sets color-scheme: dark so native controls and scrollbars follow. |
dark: | Tailwind variant, declared as @custom-variant dark (&:where(.dark, .dark *)): it matches inside any .dark ancestor. Needed only for fixed brand colours and Lift shadows. |
data-theme | The theme script and useTheme also set data-theme="light|dark" on <html>, for hosts and embeds that watch it. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Left arrowUp arrow | In the theme switch: chooses the previous theme (from the first, the last). |
| Right arrowDown arrow | Chooses the next theme (from the last, the first). |
| Tab | Moves focus into the switch (onto the chosen theme) and out again. |
- The switch is a radio group (role="radiogroup" with role="radio" buttons and aria-checked), named “Theme”, with one tab stop.
- The first paint already has the right theme, so nothing flashes for people sensitive to sudden brightness.
- Every token is tuned per theme for contrast; the dark greys are not just inverted.
- The cross-fade is skipped when the visitor prefers reduced motion.
Guidelines
Do
- Default to System and offer Light and Dark beside it.
- Set the class before the first paint, in an inline script in <head>.
- Style with tokens so components need no dark: classes.
Don’t
- Don’t read the theme during render on prerendered pages; read it in an effect or through useSyncExternalStore.
- Don’t invert colours with a filter; the dark tokens are tuned on their own.
- Don’t hide the theme switch only in a menu; keep it one press away.
In use
Used on this site
Related
- ColourTwelve semantic greys that switch with the theme, and six fixed brand values for the logo and brand panels.
- Segmented controlA radio group drawn as one strip, with a lit pill that glides to the choice and arrow keys that move and choose.
- Easing & transitionsFour motion curves, a soft cross-fade for view changes, a reduced-motion hook and disclosures that ease open.
- Auth layoutThe frame for every sign-in page: a dark brand panel when there is room, one calm form column, the logo on top and legal links below.
Source
The component’s own files, exactly as they run on this site.
@import "tailwindcss";@import "tw-animate-css";@import "shadcn/tailwind.css";@import "../styles/brand.css";@import "../styles/effects.css";@import "../styles/luxe.css";@import "../styles/product.css";
/* Class-based dark mode: `dark` on <html> (or any wrapper) — matches the element itself too. */@custom-variant dark (&:where(.dark, .dark *));
@theme inline { --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);