Skip to content

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.

Light

Payment run

Scheduled
  • Northwind FreightNet 30$18,240.00
  • HalcyonEarly pay$12,940.50
Cash available$31,180 of $50,000
Dark

Payment run

Scheduled
  • Northwind FreightNet 30$18,240.00
  • HalcyonEarly pay$12,940.50
Cash available$31,180 of $50,000
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.

  1. The preference is stored under oration-theme in localStorage as light, dark or system. Nothing stored means system.
  2. An inline script in the <head> of apps/web/src/app/layout.tsx reads it, resolves system through prefers-color-scheme and toggles dark on <html> before the body paints, so there is no flash of the wrong theme. suppressHydrationWarning on <html> lets React accept the class it didn't render.
  3. ThemeProvider reads the same key on mount and exposes useTheme(): theme (the preference), resolved (what is drawn) and setTheme. While the preference is system it follows OS changes live.
  4. 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, every transition-colors on the page would fade at once.
The bootstrap script
// apps/web/src/app/layout.tsxconst themeScript = …;<html lang="en" suppressHydrationWarning className={…}>  <head>    <script dangerouslySetInnerHTML={{ __html: themeScript }} />  </head>  <body>    <ThemeProvider>…</ThemeProvider>  </body></html>
Swapping without transitions
// 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>    );}
Reading and setting the theme in the app
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.

The variant and the two sets
/* 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 */}
What each piece does
PieceEffect
.dark on <html>The page theme. Set by the bootstrap script and ThemeProvider, never by hand.
.dark on an elementRedefines every role for that subtree. Docs previews use it to pin a specimen to dark.
.light on an elementShares the :root block, so it restores the light set inside a dark page or a dark scope.
dark: utilitiesApply 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.

A .dark scope
A .light scope inside it
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>    );}
Scoping a preview
{/* 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.

Surface levels in light
Cool RailL 0.978
White PlaneL 1
Row MistL 0.985
Card WhiteL 1
Popover WhiteL 1
Surface levels in dark
Cool RailL 0.158
White PlaneL 0.178
Row MistL 0.198
Card WhiteL 0.205
Popover WhiteL 0.225
What changes between the themes
WhatLightDark
SurfacesPlane, 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).
ShadowsTinted 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 ring0 0 0 1px oklch(0.2 0.02 265 / 0.07)A white hairline, 0 0 0 1px oklch(1 0 0 / 0.07).
LinesOpaque 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 fillTransparent.dark:bg-input/30, on inputs, selects, checkboxes and outline buttons. Disabled fields go to input/80.
ScrimBlack at 25% under dialogs and sheets.Black at 55%.
TagsPale 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 Indigooklch(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 tintSignal Red at 10%.Signal Red at 20%.
Chart InkDark 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.

A .light scope
A .dark scope
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-black or Tailwind palette colors.
  • Hovers and overlays use roles (bg-muted, bg-accent, bg-foreground/5), not bg-black/5.
  • Rings that cut a shape out of its surface, such as avatar stacks and the meter target, use ring-card or ring-background to match what they sit on, not ring-white.
  • Shadows come from shadow-*. No hand-written box-shadow with its own color.
  • SVG and illustrations draw in currentColor or token fills such as fill-card and stroke-chart-1.
  • No dark: in product code. A role that needs a different alpha in dark gets it inside its packages/canon component.
  • 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.
MOPRTFJL
MOPRTFJL
Do. Cut avatar rings from the surface with ring-card, so the gap matches the card in either theme.
MOPRTFJL
MOPRTFJL
Don't. Use ring-white. On a dark card every avatar wears a bright halo.
HalcyonOrchard Street
HalcyonOrchard Street
Do. Hover rows with bg-muted or bg-surface, which lift in dark.
HalcyonOrchard Street
HalcyonOrchard Street
Don't. Hover with bg-black/5. It darkens a dark row by an amount nobody can see.