Theming
Customize colors, surfaces, and shadows using OKLCH tokens and CSS custom properties.
Prototyper UI's design system is built on CSS custom properties using the OKLCH color space. Every color, surface, shadow, and easing curve is a token you can override.
OKLCH Color System
All colors use the OKLCH color space — a perceptually uniform model where lightness, chroma, and hue are independent axes.
/* OKLCH values: lightness chroma hue */
--primary: 39.11% 0.084 240.8;Why OKLCH?
- Perceptually uniform — a 10% lightness shift looks like 10% regardless of the base hue. HSL lightness is wildly inconsistent across colors.
- Predictable color-mix() — derived states (hover, soft) use
color-mix(in oklab)and produce consistent results across the entire hue range. - Wide gamut — OKLCH can represent P3 and Rec.2020 colors that HSL cannot.
How Derived Colors Work
Hover states, soft variants, and surface tiers are computed automatically using color-mix():
/* Hover: 90% base + 10% paired foreground */
--primary-hover: color-mix(in oklab, oklch(var(--primary)) 90%, oklch(var(--primary-foreground)) 10%);
/* Soft variant: 15% base on transparent */
--primary-soft: color-mix(in oklab, oklch(var(--primary)) 15%, transparent);
/* Surface tiers: progressive foreground mixing */
--surface-secondary: color-mix(in oklab, oklch(var(--surface)) 94%, oklch(var(--surface-foreground)) 6%);
--surface-tertiary: color-mix(in oklab, oklch(var(--surface)) 88%, oklch(var(--surface-foreground)) 12%);You only need to define the base color and its foreground — all interaction states derive automatically.
Token Reference
Base Colors
| Token | Description | Light Default |
|---|---|---|
--background | Page background | 100% 0 0 (white) |
--foreground | Default text color | 14.05% 0.004 285.8 |
--radius | Base border radius | 0.5rem |
Primary Ramp
| Token | Description | Light Default |
|---|---|---|
--primary | Primary brand color, buttons | 39.11% 0.084 240.8 |
--primary-foreground | Text on primary backgrounds | 98.48% 0 0 |
--primary-light | Gradient light stop | 75.84% 0.137 231.6 |
--primary-middle | Gradient middle stop | 49.96% 0.118 242.2 |
--primary-dark | Gradient dark stop | 39.20% 0.084 240.8 |
Semantic Colors
| Token | Description |
|---|---|
--secondary / --secondary-foreground | Secondary actions, subtle buttons |
--muted / --muted-foreground | Muted backgrounds, placeholder text |
--accent / --accent-foreground | Accent highlights, hover backgrounds |
--destructive / --destructive-foreground | Destructive actions, error states |
--success / --success-foreground | Success states |
--warning / --warning-foreground | Warning states |
--info / --info-foreground | Informational states |
Surfaces
| Token | Description |
|---|---|
--surface / --surface-foreground | Cards, panels, tabs |
--surface-secondary | Nested containers (derived via color-mix) |
--surface-tertiary | Deeper nesting (derived via color-mix) |
--overlay / --overlay-foreground | Dialogs, popovers, menus |
--card / --card-foreground | Card backgrounds |
--popover / --popover-foreground | Popover backgrounds |
Fields
| Token | Description |
|---|---|
--field-background | Input, select, combobox backgrounds |
--field-border | Field border color |
--field-border-hover | Field border on hover (derived) |
--field-border-invalid | Field border on validation error (derived) |
Borders
| Token | Description |
|---|---|
--border | Default border |
--border-light | Subtle border (dividers, separators) |
--border-dark | Emphasized border |
--input | Input border (matches --border) |
--ring | Focus ring color (matches --primary) |
Shadows
| Token | Light Mode | Dark Mode |
|---|---|---|
--shadow-surface | Multi-layer subtle shadow | none |
--shadow-field | Subtle shadow + 1px edge | none |
--shadow-overlay | Heavy multi-layer shadow | 1px white inset glow + deep shadow |
Easings
| Token | Value | Usage |
|---|---|---|
--ease-smooth | cubic-bezier(0.4, 0, 0.2, 1) | General transitions |
--ease-out-fluid | cubic-bezier(0.32, 0.72, 0, 1) | Signature deceleration curve |
--ease-out-quad | cubic-bezier(0.25, 0.46, 0.45, 0.94) | Subtle ease-out |
--ease-out-quart | cubic-bezier(0.165, 0.84, 0.44, 1) | Pronounced ease-out |
--ease-in-quad | cubic-bezier(0.55, 0.085, 0.68, 0.53) | Subtle ease-in |
--ease-in-quart | cubic-bezier(0.895, 0.03, 0.685, 0.22) | Pronounced ease-in |
--ease-in-out-quad | cubic-bezier(0.455, 0.03, 0.515, 0.955) | Symmetric ease |
Derived Interaction Colors
These are auto-computed from base tokens — you typically do not override them:
| Token | Formula |
|---|---|
--primary-hover | 90% --primary + 10% --primary-foreground |
--destructive-hover | 90% --destructive + 10% --destructive-foreground |
--success-hover | 90% --success + 10% --success-foreground |
--warning-hover | 90% --warning + 10% --warning-foreground |
--accent-hover | 92% --accent + 8% --accent-foreground |
--primary-soft | 15% --primary on transparent |
--primary-soft-hover | 20% --primary on transparent |
--destructive-soft | 15% --destructive on transparent |
--destructive-soft-hover | 20% --destructive on transparent |
Customize Colors
Override any token in your globals.css to change the look of all components at once. Tokens use raw OKLCH values (lightness, chroma, hue) without the oklch() wrapper:
:root {
/* Change primary to a teal */
--primary: 55% 0.15 180;
--primary-foreground: 98% 0 0;
--primary-light: 75% 0.12 175;
--primary-middle: 60% 0.14 178;
--primary-dark: 45% 0.13 182;
/* Update the focus ring to match */
--ring: 55% 0.15 180;
}
.dark {
--primary: 40% 0.10 180;
--primary-foreground: 95% 0.02 178;
--primary-light: 75% 0.12 175;
--primary-middle: 60% 0.14 178;
--primary-dark: 45% 0.13 182;
--ring: 70% 0.08 178;
}All derived tokens (--primary-hover, --primary-soft, etc.) will auto-adapt because they use color-mix() with your new base values.
Create a Custom Theme
Here is a full example of a custom theme with both light and dark modes. Copy this into your globals.css and adjust the values:
:root {
/* Base */
--background: 100% 0 0;
--foreground: 14.05% 0.004 285.8;
--radius: 0.5rem;
/* Primary — violet example */
--primary: 50% 0.2 280;
--primary-foreground: 98% 0 0;
--primary-light: 75% 0.15 275;
--primary-middle: 58% 0.18 278;
--primary-dark: 42% 0.19 282;
/* Secondary */
--secondary: 96% 0.01 280;
--secondary-foreground: 25% 0.03 280;
/* Muted */
--muted: 96% 0 0;
--muted-foreground: 55% 0.01 285;
/* Accent */
--accent: 96% 0 0;
--accent-foreground: 21% 0.006 285;
/* Destructive */
--destructive: 63% 0.21 25;
--destructive-foreground: 98% 0 0;
/* Borders */
--border: 92% 0.004 286;
--border-light: 96% 0 0;
--border-dark: 80% 0.01 286;
--input: 92% 0.004 286;
--ring: 50% 0.2 280;
/* Surfaces */
--surface: 98% 0 0;
--surface-foreground: 14% 0.004 285;
--surface-secondary: color-mix(in oklab, oklch(var(--surface)) 94%, oklch(var(--surface-foreground)) 6%);
--surface-tertiary: color-mix(in oklab, oklch(var(--surface)) 88%, oklch(var(--surface-foreground)) 12%);
--overlay: 100% 0 0;
--overlay-foreground: 14% 0.004 285;
/* Cards & Popovers */
--card: 98% 0 0;
--card-foreground: 14% 0.004 285;
--popover: 100% 0 0;
--popover-foreground: 14% 0.004 285;
/* Fields */
--field-background: 100% 0 0;
--field-border: 92% 0.004 286;
--field-border-hover: color-mix(in oklab, oklch(var(--field-border)) 70%, oklch(var(--foreground)) 30%);
--field-border-invalid: oklch(var(--destructive));
/* Derived hover states — auto-adapt */
--primary-hover: color-mix(in oklab, oklch(var(--primary)) 90%, oklch(var(--primary-foreground)) 10%);
--destructive-hover: color-mix(in oklab, oklch(var(--destructive)) 90%, oklch(var(--destructive-foreground)) 10%);
--accent-hover: color-mix(in oklab, oklch(var(--accent)) 92%, oklch(var(--accent-foreground)) 8%);
/* Derived soft variants */
--primary-soft: color-mix(in oklab, oklch(var(--primary)) 15%, transparent);
--primary-soft-hover: color-mix(in oklab, oklch(var(--primary)) 20%, transparent);
--destructive-soft: color-mix(in oklab, oklch(var(--destructive)) 15%, transparent);
--destructive-soft-hover: color-mix(in oklab, oklch(var(--destructive)) 20%, transparent);
}
.dark {
/* Base */
--background: 14% 0.004 285;
--foreground: 98% 0 0;
/* Primary — violet dark mode */
--primary: 35% 0.14 282;
--primary-foreground: 95% 0.02 278;
--primary-light: 75% 0.15 275;
--primary-middle: 58% 0.18 278;
--primary-dark: 42% 0.19 282;
/* Secondary */
--secondary: 28% 0.03 280;
--secondary-foreground: 98% 0 0;
/* Muted */
--muted: 27% 0.006 286;
--muted-foreground: 71% 0.013 286;
/* Accent */
--accent: 27% 0.006 286;
--accent-foreground: 98% 0 0;
/* Destructive */
--destructive: 40% 0.13 26;
--destructive-foreground: 98% 0 0;
/* Borders */
--border: 32% 0.007 286;
--border-light: 37% 0.008 286;
--border-dark: 23% 0.004 286;
--input: 27% 0.006 286;
--ring: 70% 0.1 278;
/* Surfaces */
--surface: 16% 0.006 285;
--surface-foreground: 98% 0 0;
--surface-secondary: color-mix(in oklab, oklch(var(--surface)) 94%, oklch(var(--surface-foreground)) 6%);
--surface-tertiary: color-mix(in oklab, oklch(var(--surface)) 88%, oklch(var(--surface-foreground)) 12%);
--overlay: 20% 0.008 285;
--overlay-foreground: 98% 0 0;
/* Cards & Popovers */
--card: 16% 0.006 285;
--card-foreground: 98% 0 0;
--popover: 14% 0.004 285;
--popover-foreground: 98% 0 0;
/* Fields */
--field-background: 16% 0.006 285;
--field-border: 32% 0.007 286;
--field-border-hover: color-mix(in oklab, oklch(var(--field-border)) 70%, oklch(var(--foreground)) 30%);
--field-border-invalid: oklch(var(--destructive));
/* Derived — same formulas, auto-adapt to dark values */
--primary-hover: color-mix(in oklab, oklch(var(--primary)) 90%, oklch(var(--primary-foreground)) 10%);
--destructive-hover: color-mix(in oklab, oklch(var(--destructive)) 90%, oklch(var(--destructive-foreground)) 10%);
--accent-hover: color-mix(in oklab, oklch(var(--accent)) 92%, oklch(var(--accent-foreground)) 8%);
--primary-soft: color-mix(in oklab, oklch(var(--primary)) 15%, transparent);
--primary-soft-hover: color-mix(in oklab, oklch(var(--primary)) 20%, transparent);
--destructive-soft: color-mix(in oklab, oklch(var(--destructive)) 15%, transparent);
--destructive-soft-hover: color-mix(in oklab, oklch(var(--destructive)) 20%, transparent);
/* Shadows — tonal contrast replaces shadows */
--shadow-surface: none;
--shadow-field: none;
--shadow-overlay: 0 0 0 1px oklch(100% 0 0 / 0.08), 0 8px 30px oklch(0% 0 0 / 0.35);
}What to Define vs. What to Derive
| Define manually | Derived via color-mix() |
|---|---|
Base colors (--primary, --destructive, etc.) | Hover states (90% base + 10% foreground) |
Foreground colors (--primary-foreground, etc.) | Soft variants (15% base + transparent) |
Color ramps (--primary-light/middle/dark) | Surface tiers (secondary, tertiary) |
| Surface/overlay/field backgrounds | Border hover progressions |
Foreground colors cannot be auto-derived — green needs dark text in both modes, while blue needs light text in both modes. Ramp derivation (light/middle/dark stops) collapses for light colors and washes chroma for dark colors. Define where it matters, derive where it's safe.
Runtime Theme Switching
Toggle Dark Mode
Add or remove the .dark class on the <html> element:
// Toggle dark mode
document.documentElement.classList.toggle("dark")
// Set explicitly
document.documentElement.classList.add("dark") // dark
document.documentElement.classList.remove("dark") // lightWith next-themes, use the useTheme hook:
import { useTheme } from "next-themes"
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Toggle theme
</button>
)
}data-theme Attribute
You can also switch themes using a data-theme attribute, which is useful when supporting multiple named themes beyond light/dark:
<html data-theme="dark">[data-theme="dark"] {
--background: 14.05% 0.004 285.8;
--foreground: 98.48% 0 0;
/* ... all dark mode overrides */
}Swap Color Themes at Runtime
To switch between color themes (e.g., different primary colors) at runtime, apply CSS custom property overrides via a class or attribute:
[data-theme="violet"] {
--primary: 50% 0.2 280;
--primary-foreground: 98% 0 0;
--primary-light: 75% 0.15 275;
--primary-middle: 58% 0.18 278;
--primary-dark: 42% 0.19 282;
--ring: 50% 0.2 280;
}
[data-theme="violet"].dark {
--primary: 35% 0.14 282;
--primary-foreground: 95% 0.02 278;
--ring: 70% 0.1 278;
}// Switch color theme
document.documentElement.setAttribute("data-theme", "violet")Theme Builder
Use the interactive Theme Builder to preview color combinations and generate the CSS tokens for your custom theme.