---
name: theming
description: >
  Customize design system appearance via CSS custom properties and Tailwind
  utilities. Brand color scale --brand-50 through --brand-950 (oklch).
  Semantic tokens: --primary, --secondary, --accent, --destructive, --muted,
  --background, --foreground, --card, --popover, --border, --input, --ring.
  Sidebar tokens, chart tokens. Dark mode via .dark class (not
  prefers-color-scheme). Border radius derived from --radius base. cn() utility
  (clsx + tailwind-merge). CVA for variant definitions. Override via className
  props using semantic token classes (bg-primary, text-muted-foreground). Activate
  when customizing theme, colors, dark mode, or component appearance.
type: core
library: '@loke/design-system'
library_version: '2.0.0-rc.6'
requires:
  - getting-started
sources:
  - 'LOKE/merchant-frontends:packages/design-system/src/styles/index.css'
  - 'LOKE/merchant-frontends:packages/design-system/src/styles/theme.css'
  - 'LOKE/merchant-frontends:packages/design-system/src/lib/cn'
  - 'LOKE/merchant-frontends:apps/office/src/styles.css'
---

## Dependency

This skill builds on getting-started. Read it first for CSS configuration.

## Setup

The design system uses CSS custom properties (tokens) defined on `:root` for light mode and overridden on `.dark` for dark mode. Tailwind's `@theme` block maps these tokens to Tailwind utility classes, so you write `bg-primary` or `text-muted-foreground` instead of raw color values.

```css
/* Light mode (default) */
:root {
  --primary: oklch(51.4% 0.2276 276.98);
  --primary-foreground: oklch(99.17% 0.0028 325.6);
  --background: var(--color-brand-50);
  --foreground: var(--color-brand-950);
  /* ... */
}

/* Dark mode override */
.dark {
  --primary: oklch(60% 0.25 276.98);
  --primary-foreground: oklch(15% 0.01 325.6);
  --background: oklch(15% 0.02 274.82);
  --foreground: var(--color-brand-50);
  /* ... */
}
```

In components, use the Tailwind utility classes that map to these tokens:

```tsx
<div className="bg-background text-foreground">
  <h1 className="text-primary">Heading</h1>
  <p className="text-muted-foreground">Subdued text</p>
</div>
```

## Core Patterns

### Semantic Token System

All semantic tokens defined in the design system CSS. Each has a light (`:root`) and dark (`.dark`) value. The `@theme` block maps `--token` to `--color-token`, enabling Tailwind classes like `bg-primary`, `text-card-foreground`, etc.

| Token | Tailwind Class (bg/text) | Light Value | Dark Value |
|---|---|---|---|
| `--background` | `bg-background` | `var(--color-brand-50)` | `oklch(15% 0.02 274.82)` |
| `--foreground` | `text-foreground` | `var(--color-brand-950)` | `var(--color-brand-50)` |
| `--primary` | `bg-primary`, `text-primary` | `var(--color-brand-500)` | `oklch(60% 0.25 276.98)` |
| `--primary-foreground` | `text-primary-foreground` | `oklch(99.17% 0.0028 325.6)` | `oklch(15% 0.01 325.6)` |
| `--secondary` | `bg-secondary` | `oklch(72.29% 0.1438 163.11)` | `oklch(55% 0.16 163.11)` |
| `--secondary-foreground` | `text-secondary-foreground` | `oklch(99.78% 0.0068 115.7)` | `oklch(15% 0.02 115.7)` |
| `--accent` | `bg-accent` | `oklch(96.71% 0.0029 264.54)` | `oklch(30% 0.05 264.54)` |
| `--accent-foreground` | `text-accent-foreground` | `oklch(21.03% 0.0318 264.65)` | `oklch(95% 0.04 264.65)` |
| `--destructive` | `bg-destructive` | `oklch(58.52% 0.18 24.61)` | `oklch(60% 0.22 24.61)` |
| `--destructive-foreground` | `text-destructive-foreground` | `oklch(98.48% 0.0213 193.18)` | `oklch(15% 0.02 193.18)` |
| `--muted` | `bg-muted` | `oklch(96.71% 0.0029 264.54)` | `oklch(25% 0.01 264.54)` |
| `--muted-foreground` | `text-muted-foreground` | `oklch(55.13% 0.0233 264.36)` | `oklch(75% 0.03 264.36)` |
| `--card` | `bg-card` | `oklch(99.16% 0.0029 247.85)` | `oklch(20% 0.01 247.85)` |
| `--card-foreground` | `text-card-foreground` | `oklch(37.39% 0.0822 285.49)` | `oklch(85% 0.04 283.75)` |
| `--popover` | `bg-popover` | `oklch(99.16% 0.0029 247.85)` | `oklch(18% 0.015 247.85)` |
| `--popover-foreground` | `text-popover-foreground` | `oklch(37.1% 0.0722 285.35)` | `oklch(92% 0.07 285.35)` |
| `--border` | `border-border` | `oklch(90.58% 0.013831 272.4947)` | `oklch(30% 0.02 272.49)` |
| `--input` | `border-input` | `oklch(87.53% 0.0142 268.67)` | `oklch(28% 0.02 268.67)` |
| `--ring` | `ring-ring` | `oklch(74.83% 0.1308 254.23)` | `oklch(65% 0.18 254.23)` |

The base layer applies defaults globally:

```css
@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}
```

### Brand Color Scale

The brand scale defines `--brand-50` through `--brand-950` in oklch. These feed into `--primary`, `--background`, and `--foreground` via `var()` references.

| Token | Value |
|---|---|
| `--brand-50` | `oklch(96% 0.04 276.98)` |
| `--brand-100` | `oklch(92% 0.08 276.98)` |
| `--brand-200` | `oklch(83% 0.13 276.98)` |
| `--brand-300` | `oklch(74% 0.18 276.98)` |
| `--brand-400` | `oklch(63% 0.21 276.98)` |
| `--brand-500` | `oklch(51.4% 0.2276 276.98)` |
| `--brand-600` | `oklch(42% 0.2 276.98)` |
| `--brand-700` | `oklch(33% 0.17 276.98)` |
| `--brand-800` | `oklch(24% 0.13 276.98)` |
| `--brand-900` | `oklch(15% 0.09 276.98)` |
| `--brand-950` | `oklch(5% 0.05 276.98)` |

All brand tokens share the same hue (`276.98`). The `@theme` block maps these to `--color-brand-*`, enabling Tailwind classes like `bg-brand-500` or `text-brand-200`.

To customize the brand color, override the full scale in your consumer CSS `:root` block:

```css
:root {
  --brand-50: oklch(96% 0.03 150);
  --brand-100: oklch(92% 0.06 150);
  /* ... override all 11 steps ... */
  --brand-950: oklch(5% 0.04 150);
}
```

### Dark Mode

Dark mode is activated by adding the `.dark` class to the `<html>` or `<body>` element. The design system uses a custom variant:

```css
@custom-variant dark (&:is(.dark *));
```

This means `dark:` utilities in Tailwind only activate when an ancestor has the `.dark` class -- not via `prefers-color-scheme`. Semantic tokens auto-switch between light and dark values, so most components need no `dark:` prefix at all.

```tsx
// Toggle dark mode
document.documentElement.classList.toggle("dark");
```

### cn() Utility

`cn()` combines `clsx` (conditional class joining) with `twMerge` (Tailwind class deduplication). Import from `@loke/design-system/cn`.

```ts
import { cn } from "@loke/design-system/cn";

// Conditional classes
cn("px-4 py-2", isActive && "bg-primary text-primary-foreground");

// Override base classes -- twMerge resolves conflicts
cn("bg-muted text-sm", className); // className="bg-primary" wins over bg-muted
```

Source implementation:

```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### CVA for Component Variants

Components use `class-variance-authority` (CVA) to define variant-driven class sets. Here is the Button component as a reference:

```tsx
import { cn } from "@loke/design-system/cn";
import { cva, type VariantProps } from "class-variance-authority";

export const buttonVariants = cva(
  cn(
    "inline-flex items-center justify-center shrink-0 whitespace-nowrap",
    "rounded-lg border border-transparent text-sm font-medium",
    "disabled:pointer-events-none disabled:opacity-50",
  ),
  {
    defaultVariants: {
      size: "default",
      variant: "default",
    },
    variants: {
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "size-8",
      },
      variant: {
        default:
          "bg-primary text-primary-foreground hover:bg-brand-900 dark:hover:bg-brand-100",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/60",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
    },
  },
);
```

Variants reference semantic tokens (`bg-primary`, `text-destructive-foreground`) so they auto-switch in dark mode. The `className` prop is passed through `cn()` to allow per-instance overrides:

```tsx
<Button variant="outline" className="bg-muted">
  Custom background
</Button>
```

### Overriding Component Appearance

All components accept a `className` prop merged via `cn()`. Use semantic token classes for overrides so dark mode continues to work:

```tsx
<Card className="bg-muted" />
<Button className="bg-brand-500 hover:bg-brand-600" />
<Badge className="bg-accent text-accent-foreground" />
```

### Chart Tokens

Five chart tokens for data visualization (e.g., recharts):

| Token | Light Value | Dark Value |
|---|---|---|
| `--chart-1` | `var(--color-brand-300)` | `oklch(0.70 0.13 162)` |
| `--chart-2` | `oklch(85.59% 0.0601 285.82)` | `oklch(0.68 0.12 198)` |
| `--chart-3` | `oklch(72.29% 0.1438 163.11)` | `oklch(0.68 0.14 53)` |
| `--chart-4` | `oklch(75.5% 0.145 230.45)` | `oklch(0.66 0.18 285)` |
| `--chart-5` | `oklch(68.75% 0.154 38.2)` | `oklch(0.65 0.19 16)` |

Use in Tailwind: `bg-chart-1`, `text-chart-3`, `stroke-chart-2`, etc.

### Sidebar Tokens

Dedicated tokens for sidebar theming. The sidebar uses a dark background even in light mode by default:

| Token | Light Value | Dark Value |
|---|---|---|
| `--sidebar` | `oklch(21.03% 0.0316 264.78)` | `oklch(20% 0.01 247.85)` |
| `--sidebar-foreground` | `oklch(95.05% 0.0041 286.32)` | `oklch(97% 0.01 286.32)` |
| `--sidebar-accent` | `oklch(28.01% 0.0249 258.35)` | `oklch(28.01% 0.0249 258.35)` |
| `--sidebar-accent-foreground` | `oklch(72.47% 0.1495 160.94)` | `oklch(55% 0.16 163.11)` |
| `--sidebar-border` | `oklch(28.01% 0.0249 258.35)` | `oklch(28.01% 0.0249 258.35)` |
| `--sidebar-ring` | `oklch(74.83% 0.1308 254.23)` | `oklch(65% 0.18 254.23)` |

Tailwind classes: `bg-sidebar`, `text-sidebar-foreground`, `border-sidebar-border`, etc.

### Border Radius

Border radius derives from a single `--radius` base (default `0.5rem`):

```css
--radius-sm: calc(var(--radius) - 4px);  /* 0.125rem at default */
--radius-md: calc(var(--radius) - 2px);  /* 0.375rem at default */
--radius-lg: var(--radius);               /* 0.5rem at default */
--radius-xl: calc(var(--radius) + 4px);   /* 0.75rem at default */
```

Override `--radius` in your consumer CSS to scale all radii uniformly:

```css
:root {
  --radius: 0.75rem; /* larger, more rounded */
}
```

## Common Mistakes

### CRITICAL: Using raw color values instead of semantic tokens

```tsx
// Wrong -- breaks in dark mode, ignores theme
<div className="bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100">

// Correct -- auto-switches with theme
<div className="bg-muted text-muted-foreground">
```

Semantic tokens handle light/dark switching automatically. Raw Tailwind color classes (`gray-*`, `blue-*`) bypass the theme system entirely.

### HIGH: Overriding brand colors in Tailwind config instead of CSS

```js
// Wrong -- do not add colors to tailwind.config.js or @theme
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: { 500: "#5B21B6" },
      },
    },
  },
};

// Correct -- override CSS custom properties in your consumer CSS
// (e.g., apps/office/src/styles.css)
:root {
  --brand-50: oklch(96% 0.03 270);
  --brand-500: oklch(51% 0.23 270);
  /* ... all 11 steps ... */
}
```

The `@theme` block in `index.css` already maps `--brand-*` variables to `--color-brand-*`. Override the CSS variables, not the Tailwind config.

### HIGH: Using dark: media prefix instead of .dark class

```tsx
// Wrong -- media-based dark mode, will not match DS behavior
<div className="dark:bg-gray-800">

// Correct -- use semantic tokens that auto-switch
<div className="bg-background">

// If you must target dark mode explicitly, the .dark class variant works:
<div className="dark:bg-brand-900">
// This works because the DS defines: @custom-variant dark (&:is(.dark *));
```

The design system uses class-based dark mode (`@custom-variant dark (&:is(.dark *))`), not `prefers-color-scheme`. Semantic tokens are the preferred path since they auto-switch without any `dark:` prefix.

### HIGH: Not importing theme CSS

```tsx
// Wrong -- components render unstyled or with wrong colors
import { Button } from "@loke/design-system/button";

// Correct -- import styles which includes theme tokens AND component styles
import "@loke/design-system/styles";
import { Button } from "@loke/design-system/button";
```

The styles import loads both the theme tokens (`:root` and `.dark` variables) and base layer styles. Without it, CSS custom properties are undefined and components fall back to browser defaults.

## See Also

- getting-started/SKILL.md -- initial CSS setup and @source directive
- layout/SKILL.md -- responsive props interact with Tailwind utilities (tension)
