# Theming and tokens

Customize shared colors and surfaces without replacing component structure.

## Load the shared stylesheet

Complete [Installation](/docs/installation/) before changing colors. The base stylesheet includes the token definitions, Tailwind mapping, shared recipes, and motion rules used by installed components. Individual components can also import their own CSS files; preserve those imports.

## Light and dark

The default page background is light gray in Light and a dark base surface in Dark. Both the document body and `bg-background` use `--sp-semantic-color-background`; `--sp-rw-bg-base` references that same token. Raised Cards use the separate `--sp-rw-bg-secondary` surface, so they remain distinct from the page. Use `bg-background` for application canvases and the shared Card component for card surfaces.

The kit recognizes `.dark` and `data-theme="dark"` for dark variants. It stays light until the application sets one of them; it does not follow the operating system’s dark preference by itself. Set an explicit `data-theme` at the application boundary and pass the same value to SiteplaneUIProvider so kit portals preserve it. The provider is not a theme controller: the application owns system preference detection, persistence, and the theme toggle.

```tsx
import { SiteplaneUIProvider } from "@/siteplane/system/provider/siteplane-ui-provider";

<SiteplaneUIProvider data-theme="dark">
  Application content
</SiteplaneUIProvider>;
```

## Application-wide dark mode

The theme button in this documentation changes the documentation website. In your own app, connect a theme controller to the kit provider. This example uses `next-themes`, the same controller used by these docs, for system preference, saved selection and synchronization between browser tabs. It works with both Next.js and Vite; the kit itself remains independent of it.

Install the controller and the components used below after the one-time setup:

```bash
pnpm add next-themes@0.4.6
pnpm dlx shadcn@4.18.0 add @siteplane/button @siteplane/dialog
```

For npm, use `npm install next-themes@0.4.6` and `npx shadcn@4.18.0 add @siteplane/button @siteplane/dialog`.

### Shared provider and controls

Save this complete file in either framework. `ThemeProvider` writes the resolved theme to the document as a class and as `data-theme`, so a Light choice stays light when the operating system prefers dark. `SiteplaneUIProvider` passes the same light/dark value to kit portals. Use `resolvedTheme`, because a saved choice of `system` is not itself a color theme.

```tsx title="src/components/app-theme.tsx"
"use client";

import { useSyncExternalStore, type ReactNode } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { SiteplaneUIProvider } from "@/siteplane/system/provider/siteplane-ui-provider";
import { Button } from "@/siteplane/components/button";

const subscribe = () => () => {};
const getMounted = () => true;
const getServerMounted = () => false;

function useMounted() {
  return useSyncExternalStore(subscribe, getMounted, getServerMounted);
}

function KitTheme({ children }: { children: ReactNode }) {
  const { resolvedTheme } = useTheme();
  const mounted = useMounted();
  return (
    <SiteplaneUIProvider
      data-theme={
        mounted ? (resolvedTheme === "dark" ? "dark" : "light") : undefined
      }
    >
      {children}
    </SiteplaneUIProvider>
  );
}

export function AppTheme({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider
      attribute={["class", "data-theme"]}
      defaultTheme="system"
      enableSystem
      storageKey="app-theme"
    >
      <KitTheme>{children}</KitTheme>
    </ThemeProvider>
  );
}

export function ThemeControls() {
  const { theme, setTheme } = useTheme();
  const mounted = useMounted();
  return (
    <div role="group" aria-label="Color theme" className="flex flex-wrap gap-2">
      {(["light", "dark", "system"] as const).map((choice) => (
        <Button
          key={choice}
          size="sm"
          variant={mounted && theme === choice ? "default" : "outline"}
          disabled={!mounted}
          aria-pressed={mounted && theme === choice}
          onClick={() => setTheme(choice)}
        >
          {choice === "light" ? "Light" : choice === "dark" ? "Dark" : "System"}
        </Button>
      ))}
    </div>
  );
}
```

The server cannot read a browser’s saved choice. The mounted check keeps the first server/client render consistent without hiding your application. If your app already has a theme controller, keep it and connect its resolved light/dark value to SiteplaneUIProvider; do not mount competing controllers.

### Next.js root layout

Wrap the app once. Keep your existing global CSS and font imports. `suppressHydrationWarning` belongs on `html` because the theme controller updates that element before hydration; it should not be added throughout the component tree.

```tsx title="src/app/layout.tsx"
import "@fontsource-variable/inter";
import "./globals.css";
import { AppTheme } from "@/components/app-theme";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-background text-foreground">
        <AppTheme>{children}</AppTheme>
      </body>
    </html>
  );
}
```

### Vite application entry

Use the same `app-theme.tsx` file and wrap App at the entry point. Keep the global CSS imported once.

```tsx title="src/main.tsx"
import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter";
import "./index.css";
import App from "./App";
import { AppTheme } from "@/components/app-theme";

createRoot(document.getElementById("root")!).render(
  <AppTheme>
    <App />
  </AppTheme>,
);
```

### Try the theme and an overlay

Use this as `src/app/page.tsx` in Next.js or `src/App.tsx` in Vite. Select Dark, open the dialog, then reload. The document and dialog should agree, and Dark should remain selected. Select Light while your operating system uses dark mode; the page and dialog should stay light. Choose System to follow your operating system again; test an OS theme change while that choice is active.

```tsx title="Application page"
"use client";

import { ThemeControls } from "@/components/app-theme";
import { Button } from "@/siteplane/components/button";
import {
  Dialog,
  DialogTrigger,
  DialogPopup,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from "@/siteplane/components/dialog";

export default function App() {
  return (
    <main className="flex min-h-screen flex-col items-start gap-6 bg-background p-6 text-foreground">
      <h1 className="text-2xl font-semibold">Application appearance</h1>
      <ThemeControls />
      <Dialog>
        <DialogTrigger render={<Button />}>Check dialog theme</DialogTrigger>
        <DialogPopup>
          <DialogHeader>
            <DialogTitle>Theme check</DialogTitle>
            <DialogDescription>
              This portal follows the application theme.
            </DialogDescription>
          </DialogHeader>
        </DialogPopup>
      </Dialog>
    </main>
  );
}
```

For an icon switch, use SunMoonMorphToggle or LayeredThemeToggle from [Theme Toggles](/docs/components/theme-toggles/): set `pressed` to `resolvedTheme === "dark"` and `onPressedChange` to `(pressed) => setTheme(pressed ? "dark" : "light")` after mounting. The toggle shows the sun in light mode and the moon in dark mode; its accessible name stays "Dark mode" while `aria-pressed` reports the state. Keep a System option if users should be able to restore system preference. The toggle presents the control; the controller applies and remembers the choice.

## Override component colors

Put application overrides after the base import. The current kit’s control surfaces use `--sp-rw-*` role tokens. For example, Button’s `accent` variant reads `--sp-rw-bg-accent`; changing `--primary` alone does not change that button. Tailwind aliases such as `--primary` still serve utilities that reference them, but do not control every kit surface.

This example turns the accent purple. Besides the accent surface, it sets the accent text, the text on accent fills, the translucent fill and border and the focus border. `--sp-rw-progress-fill`, the fill of Progress, Meter and the File Upload progress, derives from `--sp-rw-bg-accent` by itself, a deeper accent in Light and a lighter one in Dark, so progress turns purple too; set it only to choose another fill. Charts take their brand shades from the accent in Light and from the progress fill in Dark, so they follow the accent without tokens of their own. Keep `--sp-rw-border-focus` fully opaque: the focus indicator needs its contrast at every pixel density. Define both theme scopes because dark-theme variables are set on `.dark` and `data-theme="dark"` boundaries, including providers and portals.

```css
:root,
:is(.light, [data-theme="light"]) {
  --sp-rw-bg-accent: #7c3aed;
  --sp-rw-fg-accent: #6d28d9;
  --sp-rw-fg-on-accent: #ffffff;
  --sp-rw-bg-accent-translucent: rgb(124 58 237 / 16%);
  --sp-rw-border-accent: rgb(124 58 237 / 40%);
  --sp-rw-border-focus: #7c3aed;
}
:is(.dark, [data-theme="dark"]) {
  --sp-rw-bg-accent: #7c3aed;
  --sp-rw-fg-accent: #c4b5fd;
  --sp-rw-fg-on-accent: #ffffff;
  --sp-rw-bg-accent-translucent: rgb(124 58 237 / 20%);
  --sp-rw-border-accent: rgb(167 139 250 / 60%);
  --sp-rw-border-focus: #a78bfa;
}
```

`--sp-rw-fg-on-accent` is the color of text and glyphs on an accent fill: the label of the accent Button and Badge, the check of the verified Badge, the checks of Checkbox, Radio and Table selection, and a selected Calendar day. It is `--sp-rw-fg-emphasis`, white, by default, which suits a dark or saturated brand like this purple. A light brand, such as a yellow `--sp-rw-bg-accent`, needs dark text on its fills: set `--sp-rw-fg-on-accent` to a dark color, for example `#1c1917`, in both theme scopes. Destructive fills keep `--sp-rw-fg-emphasis`, so they stay white on red.

Install [Button](/docs/components/button/) and render an accent variant to see the surface change:

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

<Button variant="accent">Save changes</Button>;
```

The [Badge light](/docs/components/badge/#light) pairs the accent with an amber highlight. Override `--sp-rw-bg-highlight` in both theme scopes if your brand needs a different second light.

These are example values, not a complete theme. Other roles include `--sp-rw-bg-primary` for primary surfaces and `--sp-rw-fg-primary` for primary text. Inspect the installed component and `siteplane/system/styles/design-rework.css` for the tokens it actually consumes. Check foreground/background contrast, disabled states, hover, focus rings, and overlays in both themes. Do not communicate status through color alone.

## Text colors

Text takes one of four levels, from the most to the least prominent. Each is a token with a value for Light and for Dark, given here as its OKLCH lightness:

| Level      | Token                   | Light | Dark         | For                            |
| ---------- | ----------------------- | ----- | ------------ | ------------------------------ |
| Primary    | `--sp-rw-fg-primary`    | 10%   | 95%          | Body text, titles and values   |
| Secondary  | `--sp-rw-fg-secondary`  | 30%   | 81%          | Labels and navigation          |
| Tertiary   | `--sp-rw-fg-tertiary`   | 50%   | 62%          | Descriptions and quieter text  |
| Quaternary | `--sp-rw-fg-quaternary` | 69%   | white at 35% | Placeholders and disabled text |

The shadcn-compatible text colors take these tokens in both themes, so Tailwind's `text-foreground` and the kit's own text are the same grey. `--sp-semantic-color-foreground`, `card-foreground`, `popover-foreground` and `secondary-foreground` alias `--sp-rw-fg-primary`, `muted-foreground` aliases `--sp-rw-fg-tertiary` and `control-disabled-foreground` aliases `--sp-rw-fg-quaternary`; `primary-foreground`, the text on the primary fill, aliases `--sp-rw-fg-emphasis`. Override a `--sp-rw-fg-*` token to change a level everywhere, and the aliases follow. The status text colors, such as `text-destructive-foreground`, keep their own values.

## Typeface

Siteplane UI is set in Inter at weight 400 with -0.015em tracking. Three tokens carry the typeface, and every component reads them. `--sp-rw-font-family-default` sets the family, `--sp-rw-letter-spacing-default` the tracking and `--sp-rw-font-weight-default` the weight of text, controls and inputs.

Text with more weight than body text takes one of three role tokens:

| Token                        | Default | Used by                                                                                                                                                                            |
| ---------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--sp-rw-font-weight-title`  | `450`   | The titles of Card, Dialog, Sheet, Drawer and Alert Dialog, and the section heading of Page Header                                                                                 |
| `--sp-rw-font-weight-label`  | `500`   | Labels and small titles, such as Field labels, Label, the group labels of Select and Combobox, and the titles of Alert and Toast                                                   |
| `--sp-rw-font-weight-strong` | `600`   | Semibold emphasis, such as the Fieldset legend, the title of the tinted Card header and of a Frame, the name in the Sidebar's logo, and the section and total rows of Native Table |

Buttons read three tokens of their own, `--sp-rw-button-font-family`, `--sp-rw-button-font-weight` and `--sp-rw-button-letter-spacing`. Each is unset and falls back to the typeface token of the same role, so Button labels can take another weight than body text ([Button](/docs/components/button/#variants)). Badges read `--sp-rw-badge-font-weight`, which is unset and falls back to their weight of 450 ([Badge](/docs/components/badge/#sizes)). Text sizes stay with each component's size scale.

The kit renders text with grayscale antialiasing: `-webkit-font-smoothing: antialiased` and `-moz-osx-font-smoothing: grayscale` on the root, which portals inherit. Without them, browsers on macOS apply the system's font smoothing, which draws text heavier. Systems other than macOS ignore both properties. To change it, set your own `font-smoothing` after the base import, which wins over the kit's rule.

To set the kit in another typeface, load it and set the three tokens after the base import. This example uses Geist from Fontsource:

```bash
pnpm add @fontsource-variable/geist
```

```tsx title="Application entry"
import "@fontsource-variable/geist";
```

```css title="Global CSS"
@import "../siteplane/system/styles/base.css";
@source "..";

:root {
  --sp-rw-font-family-default:
    "Geist Variable", ui-sans-serif, system-ui, sans-serif;
  --sp-rw-letter-spacing-default: 0em;
  --sp-rw-font-weight-default: 400;
}
```

Each typeface needs its own tracking and weight to match the kit’s proportions. The tracking and the text weight below were tuned on the kit’s components. The role weights keep Inter's steps above the text weight, so a typeface set heavier, such as Wix Madefor Text, sets its roles heavier by the same amount:

| Typeface         | Fontsource package                      | Family                                             | Tracking   | Text  | Title | Label | Strong |
| ---------------- | --------------------------------------- | -------------------------------------------------- | ---------- | ----- | ----- | ----- | ------ |
| Inter (default)  | `@fontsource-variable/inter`            | `"Inter Variable"`                                 | `-0.015em` | `400` | `450` | `500` | `600`  |
| Wix Madefor Text | `@fontsource-variable/wix-madefor-text` | `"Wix Madefor Text Variable"`                      | `-0.02em`  | `450` | `500` | `550` | `650`  |
| Geist            | `@fontsource-variable/geist`            | `"Geist Variable"`                                 | `0em`      | `400` | `450` | `500` | `600`  |
| Roboto           | `@fontsource-variable/roboto`           | `"Roboto Variable"`                                | `0.01em`   | `400` | `450` | `500` | `600`  |
| DM Sans          | `@fontsource-variable/dm-sans`          | `"DM Sans Variable"`                               | `-0.01em`  | `400` | `450` | `500` | `600`  |
| System font      | None                                    | `system-ui, -apple-system, "Segoe UI", sans-serif` | `-0.005em` | `400` | `450` | `500` | `600`  |

Text is `--sp-rw-font-weight-default`; Title, Label and Strong are the role tokens above, which need setting only where they differ from the defaults.

Follow a downloaded family with `ui-sans-serif, system-ui, sans-serif` as the fallback, as in the example. Load the variable version of a typeface. A typeface without variable weights has whole cuts only, such as 400, 500 and 600: set each role token to one of its cuts, for example `--sp-rw-font-weight-title: 500`, or the browser draws a weight such as 450 in the nearest cut it has.

With `next/font`, give the font a CSS variable and put its class on `<html>`, where `:root` can read it:

```tsx title="src/app/layout.tsx"
import { Geist } from "next/font/google";
import "./globals.css";

const geist = Geist({ subsets: ["latin"], variable: "--font-geist" });

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" data-theme="light" className={geist.variable}>
      <body>{children}</body>
    </html>
  );
}
```

```css title="src/app/globals.css"
@import "../siteplane/system/styles/base.css";
@source "..";

:root {
  --sp-rw-font-family-default:
    var(--font-geist), ui-sans-serif, system-ui, sans-serif;
  --sp-rw-letter-spacing-default: 0em;
  --sp-rw-font-weight-default: 400;
}
```

Set the typeface through these tokens, not through a font class on `<body>` or Tailwind’s `font-sans`. Buttons and titles read the tokens directly, so a class on `<body>` changes the running text and leaves them in Inter.

## Override component styles

The kit's stylesheets sit in a cascade layer named `siteplane`, between Tailwind's `components` and `utilities` layers. A utility you pass through `className` therefore wins over the kit's rule for the same property, and so does CSS that your application writes outside a layer:

```tsx
<Button className="w-full">Continue</Button>
```

`siteplane/system/styles/base.css` imports Tailwind and declares the layer order on its first line. Make it the first import of your global stylesheet and remove any separate `@import "tailwindcss"`: an earlier Tailwind import declares the order without `siteplane`, and the kit's rules then win over your utilities. A stylesheet that declares its own layers lists `siteplane` between `components` and `utilities`.

## Chart colors

A chart series that names no color takes a shade of `--chart-brand`, which is the accent in Light and the progress fill in Dark, so the shades follow your accent. Up to eight series are spread from the brand color to its lightest shade. With `palette="categorical"`, each series takes its own color instead: `--chart-1` to `--chart-8`, in the order blue, orange, teal, purple, gold, cyan, rose and green. Every one of them is at least 3:1 on the card and the page of its theme, and both palettes start over from the ninth series. To change a categorical color, set its token after the base import in both theme scopes:

```css
:root,
:is(.light, [data-theme="light"]) {
  --chart-6: oklch(61% 0.11 223);
}
:is(.dark, [data-theme="dark"]) {
  --chart-6: oklch(66.5% 0.116 223);
}
```

Tailwind's `bg-chart-1` to `bg-chart-8` and the other color utilities read the same tokens.

## Shared dimensions

Use [SiteplaneUIProvider](/docs/customization/providers/) for controlSize, density, and radius. Not every component exposes all three axes. An explicit component prop overrides the provider where that prop exists; component pages list the supported dimensions.

Badges take their corner radius from the role token `--sp-rw-radius-badge`, which is `--sp-rw-radius-xs` by default. Set it after the base import to change every badge, for example to make every badge a pill:

```css
:root {
  --sp-rw-radius-badge: var(--sp-rw-radius-full);
}
```

To round a single badge, use its [shape](/docs/components/badge/#shape) prop instead.

Cards take their padding from three role tokens. Until you set them, a card's padding follows its density: `--sp-rw-space-5` (20 px), or `--sp-rw-space-4` (16 px) in the compact density. `--sp-rw-card-inset` sets all four sides, and `--sp-rw-card-inset-block` (above and below) and `--sp-rw-card-inset-inline` (the sides) take its value until you set them. The padding covers the header, the panel and the footer of every card, the sides of the tinted header and of the divided bands, and the space above and below a divided header. Set the tokens after the base import, for example for 24 px cards:

```css
:root {
  --sp-rw-card-inset: var(--sp-rw-space-6);
}
```

Or for 12 px above and below, with the sides at the density's inset:

```css
:root {
  --sp-rw-card-inset-block: var(--sp-rw-space-3);
}
```

A token you set takes the place of the density's inset in both densities. The space between the parts stays: 12 px below a header, 16 px above a footer and around the actions of a divided footer. To change one card, set the tokens in its `className`, as the [Card](/docs/components/card/#inset) page shows.

Custom application CSS properties on an ordinary wrapper do not automatically reach every portal. Use a scope that reaches the portal or the overlay’s public style/portal props, then inspect the open overlay in both themes.
