# Theming

Semantic colors, light and dark surfaces, and reusable palettes.

Page: https://sui.draco.dev/docs/theming

SUI colors describe the role of an element. A card uses `card` and `card-foreground`; a primary action uses `primary` and `primary-foreground`. The same component adapts when its palette or appearance changes.

Shared CSS color variables use OKLCH, including the seven presets and custom palette output. These values preserve the existing colors. Custom input and preset seeds remain HEX, and contrast is measured using sRGB relative luminance. The shared package provides general semantic variables; this documentation app uses `accent-foreground` for links and `ring` for focus directly.

## Semantic usage

Choose a token by purpose, then keep the surface and its matching foreground together. Use the component's built-in variant when it already expresses that purpose.

**Recommended**

```tsx
import { Button } from "@workspace/ui/components/button";

<div className="rounded-xl border border-border bg-card p-4 text-card-foreground">
  <p className="text-muted-foreground">Your changes are ready.</p>
  <Button>Save changes</Button>
</div>;
```

**Avoid for product UI**

```tsx
<div className="rounded-xl border border-gray-300 bg-white p-4 text-black">
  <p className="text-gray-500">Your changes are ready.</p>
  <button className="bg-blue-600 text-white">Save changes</button>
</div>;
```

Fixed colors can be appropriate for illustrations or external brand assets. Product surfaces, controls, and text should follow semantic tokens so they stay consistent across themes. SUI does not add a lint rule that forbids primitive colors.

## Surface hierarchy

Start with the page canvas and layer content using the surface that matches its role. The default theme uses an Apple-inspired gray canvas, white surfaces, and blue actions; dark mode uses deep gray surfaces and brighter blue accents. Inter remains the font.

| Role | Surface | Matching text | Typical use |
| --- | --- | --- | --- |
| Page | `bg-background` | `text-foreground` | Application canvas |
| Card | `bg-card` | `text-card-foreground` | Grouped content |
| Floating | `bg-popover` | `text-popover-foreground` | Menus and popovers |
| Secondary | `bg-secondary` | `text-secondary-foreground` | Secondary controls |
| Quiet | `bg-muted` | `text-muted-foreground` | Supporting content |
| Selected | `bg-accent` | `text-accent-foreground` | Selection and hover |

Surfaces express purpose rather than a fixed brightness ladder. `card` and `popover` may share a value while preserving different roles.

## Primary and status

Use `primary` for the main action and `primary-foreground` for its text. Use `accent` for selected or hovered items. Links use `accent-foreground`, which can differ from the primary seed to maintain text contrast on neutral surfaces.

Selection colors also apply to pressed Toggle and ToggleGroup items, Command highlights, current navigation links, selected options and menu items, selected table rows, choice cards, and calendar ranges. Filled selections, including Tabs, current pagination links, checkboxes, and radio buttons, use `primary` with `primary-foreground`. Focus indicators use `ring`.

Use `destructive` for destructive actions and invalid input. Its color remains independent of the selected accent. SUI does not define separate success, warning, or information color tokens; express those states with clear text and icons, or define application-specific semantic tokens.

```tsx
import { Button } from "@workspace/ui/components/button";

<Button variant="destructive">Delete workspace</Button>;
```

## Text

Pair foreground tokens with their intended surfaces. `foreground` is the default body color; `muted-foreground` supports descriptions and secondary labels. A lighter shade alone should not communicate disabled, invalid, or selected state: retain accessible labels and the control's state attributes.

## Borders and focus

`border` separates surfaces. Existing Input uses `bg-input/50` for its fill. `ring` marks keyboard focus. Keep the outline visible when customizing interactive elements.

```tsx
<a
  href="#token-reference"
  className="rounded-md text-accent-foreground underline outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring"
>
  Explore colors
</a>;
```

## Charts

Use `chart-1` through `chart-5` for data series. Every preset supplies five coordinated colors. These colors distinguish series; add legends, labels, or patterns instead of relying only on hue. Chart colors are not substitutes for text or status tokens.

## Token reference

Browse the actual default tokens by role. Each row shows its utility, its light and dark value, and buttons to copy the variable, utility, or value. The catalog reads `globals.css`; it does not maintain a separate color table.

### Example: theming-semantic-colors

```tsx
import { Button } from "@workspace/ui/components/button";
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import { useState } from "react";
import { previewTokens, tokenValue } from "../support/theming-tokens";
import type { ExampleProps } from "../types";

const groups = [
  {
    en: "Surfaces",
    zh: "表面",
    tokens: [
      ["background", "bg-background", "Page canvas", "页面画布"],
      ["card", "bg-card", "Card surface", "卡片表面"],
      ["popover", "bg-popover", "Floating surface", "浮层表面"],
      ["secondary", "bg-secondary", "Secondary control", "次要控件"],
      ["muted", "bg-muted", "Quiet surface", "低强调表面"],
      ["accent", "bg-accent", "Selection and hover", "选中与悬停"],
    ],
  },
  {
    en: "Text",
    zh: "正文",
    tokens: [
      ["foreground", "text-foreground", "Page text", "页面正文"],
      ["card-foreground", "text-card-foreground", "Text on card", "卡片正文"],
      [
        "popover-foreground",
        "text-popover-foreground",
        "Text on floating surface",
        "浮层正文",
      ],
      [
        "secondary-foreground",
        "text-secondary-foreground",
        "Text on secondary control",
        "次要控件正文",
      ],
      [
        "muted-foreground",
        "text-muted-foreground",
        "Supporting text",
        "辅助文字",
      ],
      [
        "accent-foreground",
        "text-accent-foreground",
        "Text on selection",
        "选中态正文",
      ],
    ],
  },
  {
    en: "Primary",
    zh: "主色",
    tokens: [
      ["primary", "bg-primary", "Primary action", "主要操作"],
      [
        "primary-foreground",
        "text-primary-foreground",
        "Text on primary action",
        "主要操作正文",
      ],
    ],
  },
  {
    en: "Borders and focus",
    zh: "边框与焦点",
    tokens: [
      ["border", "border-border", "Surface boundary", "表面边界"],
      ["input", "bg-input/50", "Input fill", "输入控件填充"],
      ["ring", "ring-ring", "Keyboard focus", "键盘焦点"],
    ],
  },
  {
    en: "Status",
    zh: "状态",
    tokens: [
      [
        "destructive",
        "text-destructive",
        "Destructive action or invalid input",
        "危险操作或无效输入",
      ],
    ],
  },
  {
    en: "Charts",
    zh: "图表",
    tokens: [1, 2, 3, 4, 5].map((index) => [
      `chart-${index}`,
      `fill-chart-${index}`,
      `Data series ${index}`,
      `数据系列 ${index}`,
    ]),
  },
  {
    en: "Sidebar",
    zh: "侧栏",
    tokens: [
      ["sidebar", "bg-sidebar", "Sidebar surface", "侧栏表面"],
      [
        "sidebar-foreground",
        "text-sidebar-foreground",
        "Sidebar text",
        "侧栏正文",
      ],
      [
        "sidebar-primary",
        "bg-sidebar-primary",
        "Sidebar primary action",
        "侧栏主要操作",
      ],
      [
        "sidebar-primary-foreground",
        "text-sidebar-primary-foreground",
        "Text on sidebar action",
        "侧栏主要操作正文",
      ],
      [
        "sidebar-accent",
        "bg-sidebar-accent",
        "Sidebar selected item",
        "侧栏选中项",
      ],
      [
        "sidebar-accent-foreground",
        "text-sidebar-accent-foreground",
        "Selected sidebar text",
        "侧栏选中项正文",
      ],
      [
        "sidebar-border",
        "border-sidebar-border",
        "Sidebar boundary",
        "侧栏边界",
      ],
      [
        "sidebar-ring",
        "ring-sidebar-ring",
        "Sidebar keyboard focus",
        "侧栏键盘焦点",
      ],
    ],
  },
];

export default function Example({ locale }: ExampleProps) {
  const zh = locale === "zh-CN";
  const stateLabels = zh
    ? {
        dark: "深色",
        light: "浅色",
      }
    : {
        dark: "Dark",
        light: "Light",
      };
  const [selected, setSelected] = useState(0);
  const group = groups[selected] ?? groups[0];
  const labels = {
    copy: zh ? "复制" : "Copy",
    copied: zh ? "已复制" : "Copied",
    failed: zh
      ? "复制失败，请手动复制"
      : "Copy failed. Copy the text manually.",
  };
  return (
    <div className="w-full space-y-4 text-sm">
      <fieldset className="flex min-w-0 flex-wrap gap-2">
        <legend className="sr-only">{zh ? "颜色用途" : "Color roles"}</legend>
        {groups.map((item, index) => (
          <Button
            key={item.en}
            type="button"
            size="sm"
            variant={index === selected ? "default" : "outline"}
            aria-pressed={index === selected}
            onClick={() => setSelected(index)}
          >
            {zh ? item.zh : item.en}
          </Button>
        ))}
      </fieldset>
      <ul className="divide-y divide-border rounded-2xl border border-border">
        {group?.tokens.map(([token, utility, en, cn]) => (
          <li key={token} className="grid gap-3 p-4 sm:grid-cols-2">
            <div className="min-w-0 space-y-1">
              <p className="font-semibold">{zh ? cn : en}</p>
              <InlineCopyText labels={labels}>{`--${token}`}</InlineCopyText>
              <div>
                <InlineCopyText labels={labels} variant="muted">
                  {utility ?? ""}
                </InlineCopyText>
              </div>
            </div>
            <div className="grid grid-cols-2 gap-3">
              {[false, true].map((dark) => {
                const value = tokenValue(
                  previewTokens("default", dark),
                  `--${token}`,
                );
                return (
                  <div key={String(dark)} className="min-w-0 space-y-2">
                    <div
                      aria-hidden="true"
                      className="h-12 rounded-lg border border-border"
                      style={{ background: value }}
                    />
                    <p className="text-muted-foreground">
                      {dark ? stateLabels.dark : stateLabels.light}
                    </p>
                    <InlineCopyText labels={labels}>{value}</InlineCopyText>
                  </div>
                );
              })}
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

## Appearance

Set `dark` on a parent to use dark tokens. Without that class, the application uses light tokens. Mode and palette are separate choices: `dark` controls appearance, while `data-color` selects a palette.

```html
<html data-color="pine" class="dark">
```

The following two panels use the same SUI components with isolated default light and dark variables. Edit the field, toggle the switch, or save changes to try the controls.

### Example: theming-mode-comparison

```tsx
import { cn } from "cn";
import { ThemePreview } from "../support/theming-preview";
import { previewStyle } from "../support/theming-tokens";
import type { ExampleProps } from "../types";

export default function Example({ locale }: ExampleProps) {
  const zh = locale === "zh-CN";
  const stateLabels = zh
    ? {
        dark: "深色",
        light: "浅色",
      }
    : {
        dark: "Dark",
        light: "Light",
      };
  return (
    <div className="grid w-full gap-4 text-sm sm:grid-cols-2">
      {[false, true].map((dark) => (
        <section
          key={String(dark)}
          data-color="default"
          className={cn(
            "space-y-3 rounded-2xl border border-border bg-background p-4 text-foreground",
            dark && "dark",
          )}
          style={previewStyle("default", dark)}
        >
          <h3 className="font-semibold">
            {dark ? stateLabels.dark : stateLabels.light}
          </h3>
          <ThemePreview locale={locale} />
        </section>
      ))}
    </div>
  );
}
```

For the whole documentation site, open the appearance panel and choose **Light**, **Dark**, or **System**. System is the default and follows the device preference.

## Preset palettes

Choose **Default** or one of seven presets: Bamboo, Mauve, Mist, Sand, Pine, Rose, and Lime. Default uses the base tokens in `globals.css`. Each preset has a complete five-color palette, with the colors mapped in order to `chart-1` through `chart-5`. Its primary seed controls `primary` independently; it does not have to be the first chart color.

The cards below show the full five-color palette, copyable color values, and the same interactive component preview for every theme. Use the shared light/dark control to compare them under the same appearance. The default theme has its own complete card.

Surfaces, body text, and destructive colors keep their roles when the accent changes. Primary actions, selected states, focus rings, sidebar accents, and charts follow the palette. Accessible foreground and focus colors can differ from the seed to maintain contrast. These previews stay local and do not change the site theme or browser storage.

### Example: theming-palette-playground

```tsx
import { Button } from "@workspace/ui/components/button";
import { InlineCopyText } from "@workspace/ui/components/inline-copy-text";
import { themePresets } from "@workspace/ui/lib/theme/theme";
import { cn } from "cn";
import { useState } from "react";
import { ThemePreview } from "../support/theming-preview";
import {
  previewStyle,
  previewTokens,
  tokenValue,
} from "../support/theming-tokens";
import type { ExampleProps } from "../types";

const palettes = [
  { id: "default" as const, en: "Default", zh: "默认" },
  ...themePresets,
];

export default function Example({ locale }: ExampleProps) {
  const zh = locale === "zh-CN";
  const [dark, setDark] = useState(false);
  const mode = zh
    ? { dark: "深色预览", light: "浅色预览" }
    : { dark: "Dark preview", light: "Light preview" };
  return (
    <div className="w-full space-y-6 text-sm">
      <div className="flex items-center justify-between gap-4">
        <p className="text-muted-foreground">
          {zh
            ? "每套配色包含五个协调色"
            : "Each palette contains five coordinated colors"}
        </p>
        <Button
          type="button"
          variant="outline"
          size="sm"
          aria-pressed={dark}
          onClick={() => setDark(!dark)}
        >
          {dark ? mode.dark : mode.light}
        </Button>
      </div>
      <div className="grid gap-6 lg:grid-cols-2">
        {palettes.map((preset) => {
          const tokens = previewTokens(preset.id, dark);
          const colors =
            "palette" in preset
              ? preset.palette
              : [1, 2, 3, 4, 5].map((index) =>
                  tokenValue(tokens, `--chart-${index}`),
                );
          return (
            <section
              key={preset.id}
              data-color={preset.id}
              aria-label={zh ? preset.zh : preset.en}
              className={cn(
                "grid content-start gap-5 rounded-2xl border border-border bg-background p-4 text-foreground",
                dark && "dark",
              )}
              style={previewStyle(preset.id, dark)}
            >
              <div className="flex flex-wrap items-center justify-between gap-2">
                <h3 className="font-semibold text-base">
                  {zh ? preset.zh : preset.en}
                </h3>
                {"color" in preset && (
                  <InlineCopyText
                    className="text-muted-foreground text-xs"
                    aria-label={
                      zh
                        ? `复制主色 ${preset.color}`
                        : `Copy primary color ${preset.color}`
                    }
                  >
                    {preset.color}
                  </InlineCopyText>
                )}
              </div>
              <div className="grid grid-cols-5 gap-2">
                {colors.map((color, index) => (
                  <div key={color} className="grid min-w-0 gap-2">
                    <span
                      aria-hidden="true"
                      className="h-10 rounded-lg"
                      style={{ background: `var(--chart-${index + 1})` }}
                    />
                    <InlineCopyText
                      value={color}
                      truncate={false}
                      className="justify-center gap-1 text-[10px]"
                      aria-label={
                        zh ? `复制配色 ${color}` : `Copy color ${color}`
                      }
                    >
                      {"palette" in preset ? color : `chart-${index + 1}`}
                    </InlineCopyText>
                  </div>
                ))}
              </div>
              <ThemePreview locale={locale} />
            </section>
          );
        })}
      </div>
      <p className="text-muted-foreground">
        {zh
          ? "明暗切换仅作用于这些预览，不改变整站主题或浏览器存储"
          : "Appearance switches affect these previews without changing the site theme or browser storage."}
      </p>
    </div>
  );
}
```

Lime uses a bright `#D5F267` primary with black text. Accessible links and focus rings use deeper olive shades in light mode; dark mode keeps the lime accents bright against neutral dark surfaces. Choose **Lime** in the theme panel or set `data-color="lime"`.

## Custom HEX

The preview and the site theme panel accept three- or six-digit HEX values, with or without `#`. Shorthand expands to six digits. Invalid input displays an error and preserves the current valid theme.

The primary seed stays unchanged. The shared calculation chooses black or white primary text and adjusts links, selected text, and focus colors against the light and dark surfaces. It checks ordinary text at 4.5:1 and focus colors at 3:1. Use the matching foreground token rather than assuming white text works on every primary color.

### Example: theming-custom-color

```tsx
import { Button } from "@workspace/ui/components/button";
import { ColorPicker } from "@workspace/ui/components/color-picker";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { normalizeHex } from "@workspace/ui/lib/theme/theme";
import { cn } from "cn";
import { useId, useState } from "react";
import { colorPickerLabels } from "../../lib/color-picker-labels";
import { ThemePreview } from "../support/theming-preview";
import { previewStyle } from "../support/theming-tokens";
import type { ExampleProps } from "../types";

export default function Example({ locale }: ExampleProps) {
  const zh = locale === "zh-CN";
  const id = useId();
  const [dark, setDark] = useState(false);
  const [input, setInput] = useState("#0066CC");
  const [custom, setCustom] = useState<string | null>("#0066CC");
  const [invalid, setInvalid] = useState(false);
  const mode = zh
    ? { dark: "深色预览", light: "浅色预览" }
    : { dark: "Dark preview", light: "Light preview" };
  return (
    <div className="w-full space-y-4 text-sm">
      <form
        className="flex flex-wrap items-end gap-2"
        onSubmit={(event) => {
          event.preventDefault();
          const next = normalizeHex(input);
          setInvalid(next === null);
          if (next) {
            setCustom(next);
            setInput(next);
          }
        }}
      >
        <div className="grid min-w-36 flex-1 gap-2">
          <Label htmlFor={id}>{zh ? "自定义 HEX" : "Custom HEX"}</Label>
          <Input
            id={id}
            value={input}
            onChange={(event) => setInput(event.target.value)}
            aria-invalid={invalid}
            aria-describedby={invalid ? `${id}-error` : undefined}
          />
        </div>
        <Button type="submit" variant="secondary">
          {zh ? "应用" : "Apply"}
        </Button>
        <ColorPicker
          value={custom ?? "#0066CC"}
          onValueChange={(next) => {
            setCustom(next);
            setInput(next);
            setInvalid(false);
          }}
          labels={colorPickerLabels(locale)}
        />
        <Button
          type="button"
          variant="outline"
          aria-pressed={dark}
          onClick={() => setDark(!dark)}
        >
          {dark ? mode.dark : mode.light}
        </Button>
      </form>
      {invalid && (
        <p id={`${id}-error`} role="alert" className="text-destructive">
          {zh
            ? "输入三位或六位 HEX；当前有效配色保持不变"
            : "Enter a three- or six-digit HEX. The current valid palette is unchanged."}
        </p>
      )}
      <section
        data-color="custom"
        className={cn(
          "rounded-2xl border border-border bg-background p-4 text-foreground",
          dark && "dark",
        )}
        style={previewStyle("default", dark, custom)}
      >
        <ThemePreview locale={locale} />
      </section>
    </div>
  );
}
```

## Shared theme files

Import the UI stylesheet to include the default tokens, component styles, and all seven presets:

```css
@import "@workspace/ui/globals.css";
```

The seven files live in `packages/ui/src/styles/themes`: `bamboo.css`, `mauve.css`, `mist.css`, `sand.css`, `pine.css`, `rose.css`, and `lime.css`. A setup that already loads SUI base tokens can import an individual preset:

```css
@import "@workspace/ui/themes/pine.css";
```

Select it on the root or a container with `data-color="pine"`. A dark ancestor enables its dark palette. To reset the root, use `data-color="default"` or remove the attribute and clear custom inline tokens. The default comes directly from `globals.css`; it has no separate theme file. A nested container without its own overrides inherits its parent's colors.

## Custom application themes

Use the shared helpers for custom input. Validate first, clear previous inline accent tokens, and calculate overrides only for a custom seed:

```tsx
import {
  createThemeTokens,
  getThemeId,
  normalizeHex,
  themeTokenNames,
} from "@workspace/ui/lib/theme/theme";

function applyTheme(input: string | null, dark: boolean) {
  const seed = input === null ? null : normalizeHex(input);
  if (input !== null && seed === null) return;
  const root = document.documentElement;
  root.classList.toggle("dark", dark);
  root.dataset.color = getThemeId(seed);
  for (const name of themeTokenNames) root.style.removeProperty(name);
  if (root.dataset.color === "custom") {
    for (const [name, value] of Object.entries(createThemeTokens(seed, dark))) {
      root.style.setProperty(name, value);
    }
  }
}
```

Pass `null` to restore Default. Recalculate custom overrides when appearance changes. The shared package supplies colors and calculation; your application owns mode selection, persistence, and initial restoration. Use `text-accent-foreground` for accessible links and `outline-ring` for focus. The documentation stylesheet maps Fumadocs's `--color-fd-*` variables to the shared semantic tokens; these framework aliases are not part of the shared theme files or `createThemeTokens` output.

## Creating a theme

For a reusable preset, add its stable ID, bilingual name, seed, and five chart colors to `themePresets` in `packages/ui/src/lib/theme/theme.ts`. The generator combines that definition with the current base semantic tokens in `globals.css`.

```sh
bun run --cwd packages/ui themes:generate
```

Import the resulting individual file in `globals.css`, then use its ID with `data-color`. Regenerate after changing a preset or the base tokens. Review both modes, matching foregrounds, keyboard focus, and chart legends before adopting a palette.

## Persistence

The header provides separate Appearance and Accent color controls. Appearance selects Light, Dark, or System; Accent color selects a preset palette or custom color. Changing the mode preserves the accent.

The documentation site saves its selected mode and accent in browser storage, restores them before the first paint, and responds to device changes while System is active. When storage is unavailable, it uses System and Default. The interactive examples on this page do not write those preferences.

## Right-to-left interfaces

Wrap the relevant subtree in `DirectionProvider` and prefer logical spacing utilities:

```tsx
import { DirectionProvider } from "@workspace/ui/components/direction";

<DirectionProvider direction="rtl">
  <YourApplication />
</DirectionProvider>;
```
