Theming
Change brand colors, configure light/dark mode, switch multi-theme presets, and create custom oklch themes.
Nebutra-Sailor's theming system has three independent layers that compose together:
| Layer | Package | Responsibility |
|---|---|---|
| Runtime tokens | @nebutra/tokens | CSS variables — colors, spacing, typography |
| Light/dark mode | @nebutra/tokens (next-themes) | ThemeProvider + useTheme |
| Multi-theme presets | @nebutra/theme | 6 oklch presets via data-theme attribute |
Editing CSS variables
All design tokens live in a single file. Open it and edit directly:
packages/design/tokens/styles.cssKey token reference
:root {
--brand-primary: var(--blue-9); /* Primary CTA, links, charts */
--brand-accent: var(--cyan-9); /* Accent, success highlight */
--brand-tertiary: #8b5cf6; /* Infrastructure, tertiary viz */
--brand-gradient: 135deg, var(--blue-9) 0%, var(--cyan-9) 100%;
}:root {
--neutral-1: #ffffff; /* App background */
--neutral-2: #f8fafc; /* Subtle background */
--neutral-7: #d1d5db; /* Default border */
--neutral-11: #374151; /* Secondary text */
--neutral-12: #0a0a0a; /* Primary text */
}:root {
--status-danger: #ef4444; /* Errors, breaking changes */
--status-warning: #f59e0b; /* Improvements, pending */
--status-success: #10b981; /* Fixes, completed */
--status-info: var(--brand-primary); /* Informational */
}:root {
/* 12-step Radix-style blue scale */
--blue-1: #eff6ff;
--blue-9: #0033FE; /* ← brand primary */
--blue-12: #0a1628;
/* 12-step cyan scale */
--cyan-1: #ecfeff;
--cyan-9: #0BF1C3; /* ← brand accent */
--cyan-12: #031f19;
}Rebrand with the palette generator
The fastest way to change the entire brand palette is the generator script. It rewrites all token aliases in packages/design/tokens/styles.css:
node scripts/generate-palette.mjs --primary=#7C3AED --secondary=#F59E0BPass any valid hex color for --primary (main brand) and --secondary (accent).
pnpm --filter @nebutra/landing dev
# or
pnpm --filter @nebutra/web devHot reload does not pick up changes to styles.css in all cases; a full restart is safer.
pnpm --filter @nebutra/storybook devNavigate to Design Tokens → Brand Colors to confirm the new palette rendered correctly across all scales.
Manual rebranding example
If you prefer to edit tokens by hand:
/* packages/design/tokens/styles.css */
:root {
--brand-primary: #7C3AED; /* purple */
--brand-accent: #F59E0B; /* amber */
--brand-gradient: 135deg, #7C3AED 0%, #F59E0B 100%;
/* Update the source scales too */
--blue-9: #7C3AED;
--cyan-9: #F59E0B;
}When editing the neutral scale, always update both light (:root) and dark (.dark / [data-theme]) blocks. Skipping the dark block causes contrast issues in dark mode.
Light/dark mode
Light/dark mode is managed by next-themes re-exported from @nebutra/tokens.
Setup
The ThemeProvider is already configured in apps/web/src/app/layout.tsx and apps/landing/src/app/layout.tsx. No additional setup is required.
// apps/web/src/app/layout.tsx
import { ThemeProvider } from "@nebutra/tokens";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}Switching themes programmatically
"use client";
import { useTheme } from "@nebutra/tokens";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle dark mode"
className="rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-[var(--brand-primary)]"
>
{theme === "dark" ? "Light" : "Dark"}
</button>
);
}Resolved theme
useTheme returns "system" when the user hasn't explicitly chosen a theme. Use resolvedTheme for the actual computed value:
const { resolvedTheme } = useTheme();
// → "light" | "dark"Multi-theme presets
@nebutra/theme provides 6 ready-to-use color presets built on oklch for perceptually uniform color. Each preset is a complete token override applied via the data-theme attribute on <html>.
| Preset | Character |
|---|---|
nebutra | Blue/cyan — the Nebutra brand (default) |
dark-dense | Dense information density, muted palette |
minimal | Near-monochrome, maximum whitespace |
vibrant | Saturated, playful, consumer-focused |
ocean | Deep blues and teals, calm and focused |
Switching presets
// Static — set in layout
<html data-theme="nebutra">
// Dynamic — user preference stored in a cookie or localStorage
"use client";
import { useState } from "react";
const PRESETS = ["nebutra", "dark-dense", "minimal", "vibrant", "ocean"] as const;
export function ThemePresetSelector() {
const [preset, setPreset] = useState<string>("default");
function applyPreset(name: string) {
document.documentElement.setAttribute("data-theme", name);
setPreset(name);
}
return (
<div className="flex gap-2">
{PRESETS.map((name) => (
<button
key={name}
type="button"
onClick={() => applyPreset(name)}
className={`rounded px-3 py-1 text-sm ${preset === name ? "bg-[var(--brand-primary)] text-white" : "bg-neutral-2"}`}
>
{name}
</button>
))}
</div>
);
}Creating a custom theme preset
packages/design/theme/themes.cssCopy an existing preset block and modify the oklch values. Theme names must be lowercase kebab-case.
/* packages/design/theme/themes.css */
[data-theme="forest"] {
--brand-primary: oklch(45% 0.18 145); /* forest green */
--brand-accent: oklch(75% 0.15 80); /* warm gold */
--brand-gradient: 135deg,
oklch(45% 0.18 145) 0%,
oklch(75% 0.15 80) 100%;
--neutral-1: oklch(99% 0.005 145);
--neutral-12: oklch(12% 0.02 145);
}<html data-theme="forest">Open Storybook and check the Design Tokens section to verify contrast ratios meet WCAG AA.
oklch values are specified as oklch(lightness% chroma hue). Use a tool like oklch.com to pick perceptually uniform colors.
Typography
Fonts are loaded via next/font and injected as CSS variables. No external font requests.
/* packages/design/tokens/styles.css */
:root {
--font-sans: var(--font-geist-sans), "Geist", system-ui, sans-serif; /* UI text */
--font-mono: var(--font-geist-mono), "Geist Mono", ui-monospace, monospace; /* Code, metrics */
}To change the font family, update the next/font configuration in the app layout, then update the token fallbacks in packages/design/tokens/styles.css.
Storybook Design Tokens reference
The Design Tokens section in Storybook renders every token visually:
pnpm --filter @nebutra/storybook dev
# → http://localhost:6006 → Design TokensSections available:
- Brand Colors (blue + cyan 12-step scales)
- Semantic palette (neutral scale)
- Brand gradients
- Status colors
- Typography scale
- Motion presets
- Shadow/elevation system
How is this guide?
Last updated on