Theming
CSS variables are the only theme API. Color, type, space, and motion are tokens. Components never read prefers-color-scheme themselves.
How the class works
Every palette token carries both faces with light-dark() — the light and dark values sit side by side in one :root block, and the element's color-scheme picks the side. :root declares light dark, so the page follows the OS until you force a mode with .dark or .light on <html>. There is no second copy of the dark palette to keep in sync.
No flash
THEME_SCRIPT reads localStorage (ns-theme: light, dark, or system) and sets the class before paint. In Next, inject it with next/script and strategy="beforeInteractive". ThemeProvider keeps it in sync, and useTheme reads and sets it from any client component.
Theme switch
Glide tabs bound to useTheme. The choice is saved, and System follows the OS.
"use client";
import { GlideTabs } from "@/components/ui/glide-tabs";
import { useTheme, type Theme } from "@/components/ui/theme-provider";
export function ThemeSwitch() {
const { theme, setTheme } = useTheme();
return (
<GlideTabs
aria-label="Theme"
value={theme}
onValueChange={(next) => setTheme(next as Theme)}
tabs={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
]}
/>
);
}Motion
Everything that travels rides a spring. --ease-spring settles in --duration-spring with a small overshoot: pills, surfaces, and drums. --ease-bounce overshoots further in --duration-bounce, for things that grow, like the island. Presses go down in --duration-press. Color and fades use --ease and --duration, because a spring would overshoot the color.
Stretch a duration and every component that uses it feels heavier. The curve keeps its shape. With reduced motion on, every duration is 0ms and changes land at once.
:root {
--duration-spring: 520ms;
--duration-bounce: 800ms;
}Tokens
One set. Override a token and every component that reads it follows.
/* ink */
--text-heading
--text-body
--text-sub
--text-disabled
--link
/* type */
--font-sans /* Onest */
--font-mono /* DM Mono */
/* page */
--bg
--bg-hover
--bg-well /* the track under pills */
--bg-overlay /* scrim under drawers and dialogs */
/* surface */
--face /* menus, panels, stages */
--face-line
--face-hover
--face-press
--float /* the soft two-layer drop */
--float-press
/* keys and pills */
--chrome /* a soft vertical sheen */
--chrome-hover
--chrome-press
--chrome-line
--ink /* the ink tone */
--on-ink
--key /* sky candy */
--key-face /* the key tone's gradient */
--key-face-hover
--key-face-press
--key-edge
--key-line
--on-key
/* intent */
--error
--success
--warning
--info /* each with --on-*, --*-surface, --*-line */
--line
--line-strong
--focus
--selection
/* stack */
--z-base /* 0 */
--z-nav /* 10 */
--z-overlay /* 20 — open menus rise to it */
--z-toast /* 30 */
/* scale — shadcn's type and space steps */
--type-xs … --type-2xl /* 0.75rem … 1.5rem */
--type-mono /* 0.875rem — DM Mono */
--type-mono-sm /* 0.75rem */
--control /* 2.25rem — button height */
--control-sm /* 2rem */
--space-1 … --space-14 /* N × 0.25rem, linear */
--leading-tight /* 1.25 / 0.875 */
--leading /* 1.5 */
--weight-regular
--weight-medium
--radius /* 0.625rem */
--radius-list /* --radius + a list row's inset */
/* motion */
--ease /* color and fades */
--duration /* 200ms */
--duration-press /* 100ms */
--ease-spring
--duration-spring /* 400ms */
--ease-bounce
--duration-bounce /* 640ms */Type and space use shadcn's steps — the Tailwind text sizes and the linear N × 0.25rem space ladder — under the same --type-* and --space-* names. Fonts stay yours. Unlayered rules win over the token layer.
Where chrome lives
Surfaces that open, like the morph menu, use --face, --face-line, and --float. Pills sit in a --bg-well track and wear --chrome with --chrome-line. The ink tone swaps in --ink and --on-ink; the key tone swaps in --key-face, --key-edge, and --on-key. The island and the Pro devices keep their own black glass in both themes.
Density
Set data-density="compact" on <html> or a region. It shrinks --control, so buttons like the morph menu's get shorter. Comfortable is the default.
<div data-density="compact">
<MorphMenu label="Share">…</MorphMenu>
</div>Focus
Focus is a 2px ring at 70% ink (--focus), drawn only for keyboard focus. Components that change shape draw it on the shape, so the ring grows with the surface.