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:

LayerPackageResponsibility
Runtime tokens@nebutra/tokensCSS variables — colors, spacing, typography
Light/dark mode@nebutra/tokens (next-themes)ThemeProvider + useTheme
Multi-theme presets@nebutra/theme6 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.css

Key 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=#F59E0B

Pass any valid hex color for --primary (main brand) and --secondary (accent).

pnpm --filter @nebutra/landing dev
# or
pnpm --filter @nebutra/web dev

Hot reload does not pick up changes to styles.css in all cases; a full restart is safer.

pnpm --filter @nebutra/storybook dev

Navigate to Design TokensBrand 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>.

PresetCharacter
nebutraBlue/cyan — the Nebutra brand (default)
dark-denseDense information density, muted palette
minimalNear-monochrome, maximum whitespace
vibrantSaturated, playful, consumer-focused
oceanDeep 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.css

Copy 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 Tokens

Sections 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?

Edit on GitHub

Last updated on

On this page