sigilUI

Theming

Change once, update everywhere. Sigil's three-layer token system makes theming deterministic.

How tokens work

Every visual property in Sigil flows through CSS custom properties prefixed with --s-. Components never hardcode colors, spacing, or radii — they read from variables.

This means a single edit to your token file cascades through every component that references it. No grep-and-replace. No stale overrides.

┌────────────────────────────────────────────────────┐
│  tokens.ts (or preset)                             │
│  ↓ compile                                         │
│  :root { --s-primary: oklch(0.65 0.15 280); ... }  │
│  ↓ consumed by                                     │
│  <Button /> <Card /> <Input /> ...                  │
└────────────────────────────────────────────────────┘

The three layers

LayerPurposeExample
PrimitiveRaw valuesoklch(0.65 0.15 280), 8px, 150ms
SemanticMeaning-driven aliases--s-primary, --s-radius-md, --s-duration-fast
ComponentScoped to a specific component--s-card-radius, --s-grid-cell

Higher layers reference lower ones. A component token like --s-card-radius defaults to a semantic token like --s-radius-lg, which resolves to the primitive 12px.

Creating a preset

A preset is a named token object with optional metadata. Define one in TypeScript:

presets/brand.ts
import type { SigilPreset } from "@sigil-ui/tokens";

export const brandPreset: SigilPreset = {
  name: "brand",
  metadata: {
    description: "Corporate brand theme",
    author: "Design team",
    version: "1.0.0",
  },
  tokens: {
    colors: {
      background: { light: "oklch(0.99 0 0)", dark: "oklch(0.08 0.01 260)" },
      surface: { light: "oklch(0.97 0 0)", dark: "oklch(0.13 0.01 260)" },
      "surface-elevated": { light: "oklch(0.98 0 0)", dark: "oklch(0.16 0.01 260)" },
      primary: "oklch(0.55 0.20 145)",
      "primary-hover": "oklch(0.50 0.23 145)",
      "primary-muted": "oklch(0.88 0.05 145)",
      secondary: "oklch(0.65 0.12 30)",
      text: { light: "oklch(0.15 0 0)", dark: "oklch(0.93 0 0)" },
      "text-secondary": { light: "oklch(0.40 0 0)", dark: "oklch(0.70 0 0)" },
      "text-muted": { light: "oklch(0.55 0 0)", dark: "oklch(0.55 0 0)" },
      "text-subtle": { light: "oklch(0.70 0 0)", dark: "oklch(0.40 0 0)" },
      "text-disabled": { light: "oklch(0.80 0 0)", dark: "oklch(0.30 0 0)" },
      border: { light: "oklch(0.90 0 0)", dark: "oklch(0.22 0 0)" },
      "border-muted": { light: "oklch(0.94 0 0)", dark: "oklch(0.18 0 0)" },
      "border-strong": { light: "oklch(0.80 0 0)", dark: "oklch(0.35 0 0)" },
      "border-interactive": { light: "oklch(0.55 0.20 145)", dark: "oklch(0.55 0.20 145)" },
      success: "oklch(0.65 0.17 160)",
      warning: "oklch(0.75 0.15 85)",
      error: "oklch(0.60 0.20 25)",
      info: "oklch(0.60 0.15 250)",
    },
    typography: {
      "font-display": '"Inter", system-ui, sans-serif',
      "font-body": "system-ui, -apple-system, sans-serif",
      "font-mono": '"JetBrains Mono", ui-monospace, monospace',
    },
    spacing: { scale: [4, 8, 12, 16, 24, 32, 48, 64, 80, 96], unit: "px" },
    sigil: {
      "grid-cell": "48px",
      "cross-arm": "10px",
      "cross-stroke": "1.5px",
      "rail-gap": "24px",
      "content-max": "1280px",
      "card-radius": "12px",
    },
    radius: {
      none: "0px",
      sm: "4px",
      md: "8px",
      lg: "12px",
      xl: "16px",
      "2xl": "24px",
      full: "9999px",
    },
    shadows: {
      sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
      md: "0 0 0 1px rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06), 0 2px 4px 0 rgb(0 0 0 / 0.04)",
      lg: "0 0 0 1px rgb(0 0 0 / 0.04), 0 2px 4px -2px rgb(0 0 0 / 0.06), 0 4px 8px -2px rgb(0 0 0 / 0.08)",
      xl: "0 0 0 1px rgb(0 0 0 / 0.03), 0 4px 6px -4px rgb(0 0 0 / 0.06), 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 20px 25px -5px rgb(0 0 0 / 0.06)",
    },
    motion: {
      duration: { instant: "0ms", fast: "150ms", normal: "250ms", slow: "400ms", slower: "600ms" },
      easing: {
        default: "cubic-bezier(0.16, 1, 0.3, 1)",
        in: "cubic-bezier(0.55, 0, 1, 0.45)",
        out: "cubic-bezier(0, 0.55, 0.45, 1)",
        "in-out": "cubic-bezier(0.45, 0, 0.55, 1)",
        spring: "cubic-bezier(0.34, 1.56, 0.64, 1)",
      },
    },
    borders: {
      width: { none: "0px", thin: "1px", medium: "1.5px", thick: "2px" },
    },
  },
};

Applying a preset

Use the CLI to write the selected preset imports and then validate the project:

npx @sigil-ui/cli preset brand
npx @sigil-ui/cli doctor

This updates your token CSS so the compiled --s-* variables resolve to your preset values, including light/dark mode support.

Applying a preset at runtime

Load the compiled CSS dynamically or swap the <link> tag:

<link rel="stylesheet" href="/brand.css" />

Or override variables in a scoped container:

<div style={{ "--s-primary": "oklch(0.55 0.20 145)" } as React.CSSProperties}>
  <Button variant="primary">Themed button</Button>
</div>

Dark mode

Themed color tokens include light and dark variants. The compiled CSS uses the .dark and [data-theme="dark"] selectors automatically.

Sigil works with any dark mode strategy — class-based, attribute-based, or prefers-color-scheme. The fumadocs RootProvider handles the toggle for this docs site.

Agent workflow

The key insight: your agent edits one file. When a designer says "make the primary color green," the agent updates a single token value. Every button, link, card border, and focus ring updates in lockstep.

No searching through component files. No stale overrides. No Tailwind config wrestling.

  // One line changes everything
  colors: {
-   primary: "oklch(0.65 0.15 280)",
+   primary: "oklch(0.55 0.20 145)",
  },

Tokens reference

Colors

TokenLightDark
--s-backgroundoklch(0.99 0 0)oklch(0.07 0.01 280)
--s-surfaceoklch(0.97 0 0)oklch(0.12 0.01 280)
--s-surface-elevatedoklch(0.98 0 0)oklch(0.15 0.01 280)
--s-primaryoklch(0.65 0.15 280)—
--s-primary-hoveroklch(0.60 0.18 280)—
--s-primary-mutedoklch(0.90 0.04 280)—
--s-secondaryoklch(0.70 0.12 60)—
--s-textoklch(0.15 0 0)oklch(0.93 0 0)
--s-text-secondaryoklch(0.40 0 0)oklch(0.70 0 0)
--s-borderoklch(0.90 0 0)oklch(0.22 0 0)

Spacing

TokenValue
--s-space-04px
--s-space-18px
--s-space-212px
--s-space-316px
--s-space-424px
--s-space-532px
--s-space-648px
--s-space-764px
--s-space-880px
--s-space-996px

Radius

TokenValue
--s-radius-none0px
--s-radius-sm4px
--s-radius-md8px
--s-radius-lg12px
--s-radius-xl16px
--s-radius-2xl24px
--s-radius-full9999px

Motion

Duration tokenValue
--s-duration-instant0ms
--s-duration-fast150ms
--s-duration-normal250ms
--s-duration-slow400ms
--s-duration-slower600ms
Easing tokenValue
--s-ease-defaultcubic-bezier(0.16, 1, 0.3, 1)
--s-ease-incubic-bezier(0.55, 0, 1, 0.45)
--s-ease-outcubic-bezier(0, 0.55, 0.45, 1)
--s-ease-in-outcubic-bezier(0.45, 0, 0.55, 1)
--s-ease-springcubic-bezier(0.34, 1.56, 0.64, 1)