Theming
Every theme in Grade DS — built-in or user-built — flows through a single generator that turns three hues plus a handful of presets into a complete OKLCH-based design language.
How it works
A theme is defined by a compact ThemeInput — three hues (neutral, primary, accent) and a handful of typography, spacing, radius, and effects presets. The generator composes three 11-stop OKLCH ramps, derives semantic tokens, and resolves everything else into concrete CSS values. GradeThemeProvider applies that output to :root at runtime — every component re-skins without a single line of component code changing.
OKLCH is perceptually uniform: the same lightness value looks equally light across every hue (HSL doesn't do this). That makes generated ramps feel cohesive no matter which hue you pick.
Light and dark
Grade DS ships its own mode system — no next-themes dependency. The user-facing choice is a binary light / dark toggle. Internally, the generator produces four brightness tiers —superLight, light, dark, superDark — so surfaces stacked on top of each other (card on background, popover on card) can step through tiers to establish visual hierarchy.
First paint is handled by a pre-hydration inline script in app/layout.tsx that reads localStorage or prefers-color-scheme and applies the .dark class before React hydrates — no FOUC.
Active theme
Hover each swatch for the raw OKLCH triplet. Swap themes from the palette icon in the top nav to see all three ramps change live.
Defining a theme
A complete theme fits in a small object. The generator does everything else.
import type { ThemeInput } from "@gradeui/ui";
export const myTheme: ThemeInput = {
id: "my-theme",
name: "My Theme",
description: "A custom look",
hues: {
neutral: 220, // 0–360°
primary: 260,
accent: 340,
},
chroma: {
neutral: 0.08, // 0–1 multiplier; low = subtle gray tint
primary: 1.0, // 1 = default vibrance
accent: 1.0,
},
typography: {
display: "instrumentSerif",
body: "plusJakarta",
mono: "jetbrainsMono",
scale: "default", // "compact" | "default" | "spacious"
},
spacing: { density: "default" }, // "tight" | "default" | "roomy"
radius: { style: "soft" }, // "sharp" | "subtle" | "soft" | "round" | "pill"
effects: {
shadows: "default", // "none" | "subtle" | "default" | "dramatic"
motionIntensity: 1, // 0 = instant, 2 = luxurious
},
components: {
buttonShape: "default", // "default" | "pill" | "square"
inputStyle: "outlined",
cardStyle: "flat", // "flat" | "outlined" | "elevated" | "glass"
},
};Activate it via the useGradeTheme() hook — it persists to localStorage and appears in the theme switcher automatically.
The useGradeTheme hook
Read and mutate the active theme + mode from any client component.
"use client";
import { useGradeTheme } from "@gradeui/ui";
export function ThemeToolbar() {
const {
theme, // GeneratedTheme — active theme with resolved ramps
themeId, // string
mode, // "superLight" | "light" | "dark" | "superDark"
isDark, // boolean — convenience for binary UIs
setThemeId, // (id) => void — persists
setMode, // (mode) => void — persists
themes, // GeneratedTheme[] — all built-ins + user themes
saveAndActivate,// (ThemeInput) => void — save + activate in one step
deleteTheme, // (id) => void — no-op on built-ins
} = useGradeTheme();
return (
<div>
<p>Current: {theme.name} ({mode})</p>
{themes.map(t => (
<button key={t.id} onClick={() => setThemeId(t.id)}>
{t.name}
</button>
))}
<button onClick={() => setMode(isDark ? "light" : "dark")}>
Toggle dark
</button>
</div>
);
}OKLCH tokens under the hood
The generator writes every token as a bare L C H triplet. Tailwind wraps each one with oklch(var(--x) / <alpha-value>), which keeps opacity shortcuts like bg-primary/50 working.
:root {
/* Core semantic tokens — generator output */
--background: 0.985 0.0012 175; /* → oklch(0.985 0.0012 175) */
--foreground: 0.170 0.0032 175;
--primary: 0.610 0.170 175;
--muted: 0.955 0.0032 175;
/* …and so on */
/* Ramp swatches — exposed for palette previews */
--ramp-neutral-500: 0.610 0.0136 175;
--ramp-primary-500: 0.610 0.170 175;
--ramp-accent-500: 0.610 0.170 175;
/* Typography, radius, spacing, motion — also generated */
--font-display: var(--font-sans);
--radius: 0.5rem;
--rds-density: 1;
--rds-transition-base: 200ms;
}
/* Tailwind exposes them as utilities: */
.bg-primary { background-color: oklch(var(--primary) / 1); }
.bg-primary\/50 { background-color: oklch(var(--primary) / 0.5); }
.text-foreground { color: oklch(var(--foreground) / 1); }Using theme colors
Use Tailwind utilities with semantic names — they automatically resolve to the active theme's OKLCH values.
{/* Semantic — adapts to active theme + mode */}
<div className="bg-background text-foreground" />
<div className="bg-primary text-primary-foreground" />
<div className="bg-muted text-muted-foreground" />
<div className="bg-accent text-accent-foreground" />
{/* Semantic with opacity */}
<div className="bg-primary/10 hover:bg-primary/20" />
<div className="border-border/50" />
{/* Fixed semantic colors (not hue-derived, for accessibility) */}
<div className="bg-success" />
<div className="bg-destructive" />
<div className="bg-warning" />
<div className="bg-info" />
<div className="bg-highlight" />Setting up in a consuming app
One provider, one inline script — no next-themes, no extra wrappers.
// app/layout.tsx
import {
GradeThemeProvider,
GRADE_PRE_HYDRATION_SCRIPT,
} from "@gradeui/ui";
import "@gradeui/ui/styles.css"; // MUST be imported first
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* No-FOUC inline script — reads localStorage + system preference */}
<script
dangerouslySetInnerHTML={{ __html: GRADE_PRE_HYDRATION_SCRIPT }}
/>
</head>
<body>
<GradeThemeProvider>
{children}
</GradeThemeProvider>
</body>
</html>
);
}