Theming
Light and dark from one token set, how the theme is applied, and how to preview a surface in both.
One token set, two themes#
Light and dark are the same roles with different values. A component written in role utilities is right in both without a single theme check.
The same component in both themes
One component rendered twice, once in a .light scope and once in .dark. State is shared, so check a row or edit the memo in either and both follow.
Payment run
Scheduled- Northwind FreightNet 30$18,240.00
- HalcyonEarly pay$12,940.50
Payment run
Scheduled- Northwind FreightNet 30$18,240.00
- HalcyonEarly pay$12,940.50
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Input } from "@oration/canon/components/input";import { Meter } from "@oration/canon/components/meter";import { StatusLabel } from "@oration/canon/components/status-dot";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { MoonIcon, SunIcon } from "lucide-react";import * as React from "react";export function SideBySide() { const invoices = [ { id: "nw", supplier: "Northwind Freight", amount: "$18,240.00", terms: "Net 30", hue: "gray", }, { id: "hc", supplier: "Halcyon", amount: "$12,940.50", terms: "Early pay", hue: "green", }, ] as const; const [selected, setSelected] = React.useState<Set<string>>( () => new Set(["nw"]), ); const [memo, setMemo] = React.useState("Friday run, Oct 2"); const toggle = (id: string) => setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return ( <div className="grid w-full gap-3 md:grid-cols-2"> {(["light", "dark"] as const).map((mode) => ( <div key={mode} className={cn( mode, "flex flex-col gap-3 rounded-xl bg-background p-4 text-foreground", )} > <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> {mode === "light" ? ( <SunIcon aria-hidden="true" className="size-3.5" /> ) : ( <MoonIcon aria-hidden="true" className="size-3.5" /> )} {mode === "light" ? "Light" : "Dark"} </span> <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center justify-between gap-2"> <h3 className="text-sm font-semibold"> Payment run </h3> <StatusLabel tone="primary" className="text-[13px]"> Scheduled </StatusLabel> </div> <ul className="flex flex-col rounded-[10px] bg-muted/70 py-1"> {invoices.map((invoice) => ( <li key={invoice.id} className="flex h-9 items-center gap-2.5 px-3 text-13" > <Checkbox checked={selected.has(invoice.id)} onCheckedChange={() => toggle(invoice.id) } aria-label={`Include ${invoice.supplier} (${mode} preview)`} /> <span className="min-w-0 flex-1 truncate"> {invoice.supplier} </span> <Tag color={invoice.hue}> {invoice.terms} </Tag> <span className="w-20 text-right font-medium tabular-nums"> {invoice.amount} </span> </li> ))} </ul> <div className="flex flex-col gap-1.5"> <div className="flex justify-between text-xs text-muted-foreground"> <span>Cash available</span> <span className="tabular-nums"> $31,180 of $50,000 </span> </div> <Meter label="Cash available" value={31180} max={50000} /> </div> <Input aria-label={`Memo (${mode} preview)`} value={memo} onChange={(event) => setMemo(event.target.value)} /> <div className="flex justify-end gap-2"> <Button type="button" variant="outline" size="sm"> Reschedule </Button> <Button type="button" size="sm" disabled={selected.size === 0} onClick={() => toast.add({ title: "Payment run approved", description: `${selected.size} of 2 invoices, ${memo}.`, }) } > Approve {selected.size} </Button> </div> </div> </div> ))} </div> );}Every variable in :root, .light has a counterpart in .dark, and Tailwind reads them through @theme inline, so a utility resolves against whichever set applies to the element. The token reference lists both values for every role.
How the theme is applied#
A preference in localStorage, a blocking script that sets the class before first paint, and a provider that keeps it in sync.
- The preference is stored under
oration-themeinlocalStorageaslight,darkorsystem. Nothing stored means system. - An inline script in the
<head>ofapps/web/src/app/layout.tsxreads it, resolves system throughprefers-color-schemeand togglesdarkon<html>before the body paints, so there is no flash of the wrong theme.suppressHydrationWarningon<html>lets React accept the class it didn't render. ThemeProviderreads the same key on mount and exposesuseTheme():theme(the preference),resolved(what is drawn) andsetTheme. While the preference is system it follows OS changes live.- Every swap goes through
applyTheme, which injects a style that turns off transitions and animations, toggles the class, forces a style recalculation and removes the style a millisecond later. Without it, everytransition-colorson the page would fade at once.
// apps/web/src/app/layout.tsxconst themeScript = …;<html lang="en" suppressHydrationWarning className={…}> <head> <script dangerouslySetInnerHTML={{ __html: themeScript }} /> </head> <body> <ThemeProvider>…</ThemeProvider> </body></html>// apps/web/src/components/providers/theme-provider.tsxfunction applyTheme(dark: boolean) { const root = document.documentElement; const style = document.createElement("style"); style.appendChild( document.createTextNode( "*,*::before,*::after{transition:none!important;animation-duration:0s!important}", ), ); document.head.appendChild(style); root.classList.toggle("dark", dark); window.getComputedStyle(document.body); window.setTimeout(() => style.remove(), 1);}The page's theme, live
Reads the stored preference, the class on <html>, color-scheme and two tokens as they resolve right now.
Change the theme with the switch in the header. This panel reads the page as it changes.
- localStorage oration-theme
- not set, so system
- Class on html
- none, so the :root light set
- color-scheme
- --background
- --foreground
import * as React from "react";export function ThemeReadout() { const [state, setState] = React.useState({ stored: "", dark: false, scheme: "", background: "", foreground: "", }); React.useEffect(() => { const root = document.documentElement; const read = () => { const style = getComputedStyle(root); setState({ stored: window.localStorage.getItem("oration-theme") ?? "", dark: root.classList.contains("dark"), scheme: style.colorScheme, background: style.getPropertyValue("--background").trim(), foreground: style.getPropertyValue("--foreground").trim(), }); }; read(); const observer = new MutationObserver(read); observer.observe(root, { attributes: true, attributeFilter: ["class"], }); window.addEventListener("storage", read); return () => { observer.disconnect(); window.removeEventListener("storage", read); }; }, []); const rows = [ ["localStorage oration-theme", state.stored || "not set, so system"], ["Class on html", state.dark ? "dark" : "none, so the :root light set"], ["color-scheme", state.scheme], ["--background", state.background], ["--foreground", state.foreground], ]; return ( <div className="flex w-full max-w-md flex-col gap-3"> <p className="text-13 text-muted-foreground"> Change the theme with the switch in the header. This panel reads the page as it changes. </p> <dl className="flex flex-col rounded-xl bg-card shadow-border" aria-live="polite" > {rows.map(([label, value]) => ( <div key={label} className="flex items-baseline justify-between gap-4 border-b border-border px-3 py-2 text-13 last:border-b-0" > <dt className="text-muted-foreground">{label}</dt> <dd className="truncate font-mono text-xs text-foreground"> {value} </dd> </div> ))} </dl> </div> );}import { useTheme } from "@/components/providers/theme-provider";function AppearanceSetting() { const { theme, resolved, setTheme } = useTheme(); // theme: "light" | "dark" | "system", the stored preference // resolved: "light" | "dark", what the page is drawing now return ( <SegmentedControl label="Theme" value={theme} onValueChange={setTheme} options={[ { value: "light", label: "Light" }, { value: "dark", label: "Dark" }, { value: "system", label: "System" }, ]} /> );}The browser chrome follows too: viewport.themeColor in the root layout is #ffffff for light and #141518 for dark, and each set declares its color-scheme, so scrollbars, date pickers and other native controls match.
The dark class and the light scope#
.dark swaps the variables for a subtree. .light swaps them back. Canon's dark: variant respects both.
/* packages/canon/src/styles/globals.css */@custom-variant dark (&:is(.dark *):not(:is(.light, .light *)));:root,.light { color-scheme: light; --background: oklch(1 0 0); /* …every role */}.dark { color-scheme: dark; --background: oklch(0.178 0.005 265); /* …every role again */}| Piece | Effect |
|---|---|
.dark on <html> | The page theme. Set by the bootstrap script and ThemeProvider, never by hand. |
.dark on an element | Redefines every role for that subtree. Docs previews use it to pin a specimen to dark. |
.light on an element | Shares the :root block, so it restores the light set inside a dark page or a dark scope. |
dark: utilities | Apply to descendants of .dark that aren't inside a .light. They don't apply to the element that carries .dark itself, so put the class on a wrapper. |
A light island in a dark scope
Both fields are the same Input. In the dark scope it picks up dark:bg-input/30; inside the .light island the variant stops and the field is transparent again.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";export function ScopeNesting() { return ( <div className="dark w-full max-w-lg rounded-xl bg-background p-4 text-foreground shadow-border"> <div className="flex flex-col gap-3"> <span className="text-xs text-muted-foreground"> A .dark scope </span> <div className="flex gap-2"> <Input aria-label="Supplier search in the dark scope" placeholder="Search suppliers" /> <Button type="button" variant="outline"> Filter </Button> </div> <div className="light flex flex-col gap-3 rounded-[10px] bg-background p-3 text-foreground shadow-border"> <span className="text-xs text-muted-foreground"> A .light scope inside it </span> <div className="flex gap-2"> <Input aria-label="Supplier search in the light scope" placeholder="Search suppliers" /> <Button type="button" variant="outline"> Filter </Button> </div> </div> </div> </div> );}{/* A preview pinned to dark on any page */}<div className="dark bg-background text-foreground"> <Input /> {/* gets dark:bg-input/30 */} <div className="light bg-background"> <Input /> {/* back to light, dark: utilities stop */} </div></div>Scopes exist for docs previews and design references. Product screens never pin a region to one theme; the whole app follows the page.
What changes in dark#
Dark isn't an inverted light. Surfaces step lighter as they rise, shadows turn black, and the hairline ring turns white.
| What | Light | Dark |
|---|---|---|
| Surfaces | Plane, card and popover are all oklch(1 0 0) and separate only by their lift. | Each level steps lighter: plane oklch(0.178 0.005 265), card oklch(0.205 0.006 265), popover oklch(0.225 0.007 265). The rail sits below the plane at oklch(0.158 0.005 265). |
| Shadows | Tinted with the ink hue, oklch(0.2 0.02 265 / …). | Black, oklch(0 0 0 / …), at 0.3 to 0.55 so they still read on a dark plane. |
| The hairline ring | 0 0 0 1px oklch(0.2 0.02 265 / 0.07) | A white hairline, 0 0 0 1px oklch(1 0 0 / 0.07). |
| Lines | Opaque grays: Hairline oklch(0.918 0.005 265), Field Stroke oklch(0.885 0.006 265). | White at an alpha: oklch(1 0 0 / 0.075) and oklch(1 0 0 / 0.12). |
| Input fill | Transparent. | dark:bg-input/30, on inputs, selects, checkboxes and outline buttons. Disabled fields go to input/80. |
| Scrim | Black at 25% under dialogs and sheets. | Black at 55%. |
| Tags | Pale fills and deep inks: --tag-blue is oklch(0.95 0.03 245) with oklch(0.45 0.13 250) text. | Fills deepen and inks lift: oklch(0.29 0.05 250) with oklch(0.8 0.1 245). |
| Quiet Indigo | oklch(0.52 0.19 272), focus ring the same. Hover mixes in 9% black. | Lifts to oklch(0.585 0.18 272), ring to oklch(0.65 0.16 272). Hover mixes in 8% white. |
| Destructive tint | Signal Red at 10%. | Signal Red at 20%. |
| Chart Ink | Dark graphite, oklch(0.4 0.02 265). | Light graphite, oklch(0.82 0.015 265), so series one still leads. |
Portals follow the page theme#
Menus, popovers, selects, tooltips, dialogs, sheets and toasts render into the body, outside any .dark or .light wrapper, so they take the theme of <html>.
Overlays leave the scope
Open the run schedule from each scope. The triggers follow their scopes; both popovers match the page.
import { Button } from "@oration/canon/components/button";import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger,} from "@oration/canon/components/popover";import { cn } from "@oration/canon/lib/utils";import { CalendarClockIcon } from "lucide-react";export function PortalTheme() { return ( <div className="grid w-full max-w-lg gap-3 sm:grid-cols-2"> {(["light", "dark"] as const).map((mode) => ( <div key={mode} className={cn( mode, "flex flex-col items-start gap-3 rounded-xl bg-background p-4 text-foreground shadow-border", )} > <span className="text-xs text-muted-foreground"> {mode === "light" ? "A .light scope" : "A .dark scope"} </span> <Popover> <PopoverTrigger render={ <Button type="button" variant="outline" size="sm" /> } > <CalendarClockIcon data-icon="inline-start" aria-hidden="true" /> Run schedule </PopoverTrigger> <PopoverContent> <PopoverHeader> <PopoverTitle> Friday, Oct 2 at 2:00 PM </PopoverTitle> <PopoverDescription> This popover portals to the body, so it takes the page theme, not the {mode} scope it opened from. </PopoverDescription> </PopoverHeader> </PopoverContent> </Popover> </div> ))} </div> );}In the product this never shows, because nothing is scoped. In docs and design references it is expected: the preview switch under each example recolors the preview, and overlays opened from it stay in the page theme.
To check an overlay in dark, switch the whole page with the theme switch in the header.
Theme-safe checklist#
Run through it before a screen goes to review. Each item is a way a screen has broken in dark.
- Every color is a role utility. No hex,
bg-white,text-blackor Tailwind palette colors. - Hovers and overlays use roles (
bg-muted,bg-accent,bg-foreground/5), notbg-black/5. - Rings that cut a shape out of its surface, such as avatar stacks and the meter target, use
ring-cardorring-backgroundto match what they sit on, notring-white. - Shadows come from
shadow-*. No hand-writtenbox-shadowwith its own color. - SVG and illustrations draw in
currentColoror token fills such asfill-cardandstroke-chart-1. - No
dark:in product code. A role that needs a different alpha in dark gets it inside itspackages/canoncomponent. - Menus, popovers, dialogs and toasts are checked with the whole page in dark, because they portal out of any preview scope.
- Each screen is checked in both themes before review, with the preview switch under every example or the theme switch in the header.
ring-card, so the gap matches the card in either theme.ring-white. On a dark card every avatar wears a bright halo.bg-muted or bg-surface, which lift in dark.bg-black/5. It darkens a dark row by an amount nobody can see.