Skip to main content

Source: ocean/docs/adr/ADR-045-theme-system-implementation.md | ✏️ Edit on GitHub

ADR-045: Theme System Implementation

Status

Accepted

Context

Ocean needed a sophisticated theme system that supports:

  1. Multiple color palettes (neutral, stone, zinc, gray, slate)
  2. Automatic light/dark mode variants
  3. Type-safe theme definitions
  4. Consistent design tokens across the application
  5. Easy theme switching without page reload

The system needed to integrate with Tailwind CSS while maintaining performance and developer experience.

Decision

We implemented a CSS custom properties-based theme system with the following architecture:

1. Design Token Architecture

  • Source of Truth: JSON files in /design-tokens/

    • palettes.json: Color theme definitions
    • base-tokens.json: Non-color tokens (spacing, typography, shadows)
  • Build Process: Style Dictionary generates CSS from JSON tokens

    • Outputs to src/styles/*.generated.css
    • Automatic generation on dev/build via npm scripts

2. Dual-Attribute Theme Control

  • Color Theme: data-color-theme attribute on <html>

    • Controls which color palette is active
    • CSS rules target [data-color-theme="theme-name"]
  • Light/Dark Mode: class attribute on <html>

    • Managed by next-themes for system preference support
    • CSS rules use .dark class for dark variants

3. CSS Variable Strategy

All theme values are exposed as CSS custom properties:

:root[data-color-theme='neutral'] {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* ... more variables */
}

.dark[data-color-theme='neutral'] {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* ... dark mode overrides */
}

4. React Integration

  • Providers: Two context providers manage theme state

    • ThemeProvider (next-themes): Light/dark mode
    • ColorThemeProvider: Color palette selection
  • Hooks:

    • useTheme(): Access light/dark mode controls
    • useColorTheme(): Access color palette controls

Implementation Details

File Structure

/design-tokens/
├── palettes.json # Color theme definitions
└── base-tokens.json # Non-color tokens

/src/
├── styles/
│ ├── color-themes.generated.css # Generated color themes
│ ├── tokens.generated.css # Generated base tokens
│ └── tokens.css # Import wrapper
├── config/
│ └── themes.ts # TypeScript theme types
└── hooks/
└── use-color-theme.tsx # Theme management hook

Build Scripts

{
"scripts": {
"tokens:build": "style-dictionary build",
"tokens:themes": "node tools/generate-themes-ts.js",
"tokens": "pnpm run tokens:build && pnpm run tokens:themes",
"predev": "pnpm run tokens",
"prebuild": "pnpm run tokens"
}
}

Generated File Sizes

  • color-themes.generated.css: 272 lines (all color theme variants)
  • tokens.generated.css: 394 lines (spacing, typography, etc.)

Consequences

Positive

  1. Performance: CSS custom properties enable instant theme switching without re-render
  2. Type Safety: Generated TypeScript definitions ensure compile-time safety
  3. Maintainability: Single source of truth in JSON makes updates easy
  4. Flexibility: Supports unlimited color themes with automatic dark variants
  5. Integration: Works seamlessly with Tailwind CSS utilities
  6. Developer Experience: Themes auto-generate on dev/build

Negative

  1. Build Dependency: Requires build step to generate CSS from tokens
  2. File Size: Each theme adds ~50 lines of CSS (minimal impact)
  3. Complexity: Two-provider system may be confusing initially

Neutral

  1. Browser Support: Requires browsers that support CSS custom properties (all modern browsers)
  2. Learning Curve: Developers need to understand the dual-attribute system

Example Usage

// Component using theme
export function Card({ children }) {
return <div className="bg-background text-foreground border rounded-lg">{children}</div>
}

// Theme switcher
export function ThemeSwitcher() {
const { colorTheme, setColorTheme } = useColorTheme()
const { theme, setTheme } = useTheme()

return (
<>
<select value={colorTheme} onChange={(e) => setColorTheme(e.target.value)}>
<option value="neutral">Neutral</option>
<option value="slate">Slate</option>
{/* ... more options */}
</select>

<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle dark mode
</button>
</>
)
}

References