Siteplane UI

Theming and tokens

Customize shared colors and surfaces without replacing component structure.

Markdown

Load the shared stylesheet

Complete 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.

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:

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.

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.

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.

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.

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: 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.

: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 and render an accent variant to see the surface change:

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

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

The 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:

LevelTokenLightDarkFor
Primary--sp-rw-fg-primary10%95%Body text, titles and values
Secondary--sp-rw-fg-secondary30%81%Labels and navigation
Tertiary--sp-rw-fg-tertiary50%62%Descriptions and quieter text
Quaternary--sp-rw-fg-quaternary69%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:

TokenDefaultUsed by
--sp-rw-font-weight-title450The titles of Card, Dialog, Sheet, Drawer and Alert Dialog, and the section heading of Page Header
--sp-rw-font-weight-label500Labels 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-strong600Semibold 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). Badges read --sp-rw-badge-font-weight, which is unset and falls back to their weight of 450 (Badge). 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:

pnpm add @fontsource-variable/geist
Application entry
import "@fontsource-variable/geist";
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:

TypefaceFontsource packageFamilyTrackingTextTitleLabelStrong
Inter (default)@fontsource-variable/inter"Inter Variable"-0.015em400450500600
Wix Madefor Text@fontsource-variable/wix-madefor-text"Wix Madefor Text Variable"-0.02em450500550650
Geist@fontsource-variable/geist"Geist Variable"0em400450500600
Roboto@fontsource-variable/roboto"Roboto Variable"0.01em400450500600
DM Sans@fontsource-variable/dm-sans"DM Sans Variable"-0.01em400450500600
System fontNonesystem-ui, -apple-system, "Segoe UI", sans-serif-0.005em400450500600

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:

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>
  );
}
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:

<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:

: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 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:

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

To round a single badge, use its 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:

: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:

: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 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.