Agents (llms.txt)
Octocat

Theming

What each token is for, how to change them, and how to build your own design system on top of Foundations.

Foundations ships as a neutral base theme. Colours, edges, control sizes, roundness and type roles are tokens, so you restyle them by changing values in @theme without touching components. Spacing, the type scale, hover fills and component structure stay in your code.

import {
  HouseIcon,
  ListIcon,
  MagnifyingGlassIcon,
  SquaresFourIcon,
  TrayIcon,
} from "@phosphor-icons/react/dist/ssr";

import { Badge } from "#/components/badge.tsx";
import { Button } from "#/components/button.tsx";
import { Checkbox } from "#/components/checkbox.tsx";
import { Input } from "#/components/input.tsx";
import { SegmentedControl } from "#/components/segmented-control.tsx";
import type { PreviewMeta } from "#/lib/preview.ts";

const navItems = [
  { label: "Home", icon: HouseIcon, current: true },
  { label: "Inbox", icon: TrayIcon, current: false },
];

const orders = [
  { reference: "PO-0441", status: "In transit", variant: "warning" },
  { reference: "PO-0442", status: "Delivered", variant: "success" },
  { reference: "PO-0443", status: "Delayed", variant: "error" },
] as const;

export default function ThemingLayersPreview() {
  return (
    <div className="flex min-h-120 bg-background-secondary text-sm text-foreground">
      <nav aria-label="Main" className="flex w-44 shrink-0 flex-col gap-1 p-3">
        {navItems.map(({ label, icon: Icon, current }) => (
          <a
            key={label}
            href="#theming"
            aria-current={current ? "page" : undefined}
            className="flex h-8 items-center gap-2 rounded-lg px-2 font-medium text-foreground-secondary aria-[current=page]:bg-control aria-[current=page]:text-foreground aria-[current=page]:shadow-control"
          >
            <Icon className="size-4" />
            {label}
          </a>
        ))}
      </nav>
      <main className="m-2 ml-0 flex flex-1 flex-col gap-4 rounded-xl border border-border bg-background p-5">
        <header className="flex items-center gap-3">
          <h2 className="text-base font-semibold">Orders</h2>
          <Badge size="sm">12 open</Badge>
          <div className="ml-auto flex gap-2">
            <Button variant="outline" size="sm">
              Export
            </Button>
            <Button size="sm">New order</Button>
          </div>
        </header>
        <div className="flex items-center gap-3">
          <Input.Group size="sm" className="max-w-60">
            <Input.Addon>
              <MagnifyingGlassIcon />
            </Input.Addon>
            <Input placeholder="Search orders" />
          </Input.Group>
          <SegmentedControl defaultValue="list" className="ml-auto">
            <SegmentedControl.Item value="list">
              <ListIcon />
              <span>List</span>
            </SegmentedControl.Item>
            <SegmentedControl.Item value="board">
              <SquaresFourIcon />
              <span>Board</span>
            </SegmentedControl.Item>
          </SegmentedControl>
        </div>
        <section className="rounded-xl border border-border bg-panel shadow-panel">
          {orders.map(({ reference, status, variant }) => (
            <div
              key={reference}
              className="flex h-12 items-center gap-3 border-b border-border-subtle px-4 last:border-0"
            >
              <Checkbox aria-label={`Select ${reference}`} />
              <span className="font-mono text-foreground-secondary">{reference}</span>
              <Badge variant={variant} size="sm" className="ml-auto">
                {status}
              </Badge>
            </div>
          ))}
        </section>
        <div className="flex items-center gap-3 rounded-xl bg-background p-3 text-foreground scheme-dark dark:scheme-light">
          <span>2 selected</span>
          <Button variant="outline" size="sm" className="ml-auto">
            Compare
          </Button>
          <Button size="sm">Archive</Button>
        </div>
      </main>
    </div>
  );
}

export const meta = {
  layout: "fullscreen",
  mode: "iframe",
} satisfies PreviewMeta;

Tokens

TokenWhat it isUsed by
Layers
backgroundThe canvasThe page
panelAnything raised over the canvasPopover, Menu, Listbox, Dialog, Drawer, Toast, DatePicker, bordered Table, file rows
background-secondaryA region set apart from the canvasYour sidebars, wells, app frames
controlThe face of a controlInput, outline Button, Checkbox, Radio, SegmentedControl indicator
Text
foregroundText and iconsEverything
foreground-secondaryQuieter text that still passes WCAG AADescriptions, placeholders, metadata
accent / accent-foregroundThe primary action, and text on it (not a hover fill, as in shadcn)Primary Button, selected states, focus
Lines
borderEdges of controls and panels. Translucent, so it looks the same on any layerInputs, outline Buttons, panels
border-subtleSeparators inside a layerTable rows, Divider, Menu sections, Tabs
ringThe focus ringEvery focusable control
Status
success warning error infoStatus huesBadge, Toast, invalid fields
Edges and press
shadow-control / -pressedA control’s edge, at rest and pressedButton, Input, Checkbox, Radio, Slider
shadow-panelThe resting edge of a panel in the flowBordered Table, file rows, your cards
shadow-sm … lgThings that floatPopover, Menu, Dialog, Toast
--press-scale / --press-offsetHow a pressed button shrinks or sinksButton, Toggle
Size and shape
--control-height-xs … lgControl heights, shared so controls line upButton, Input, Select, Toggle, Pagination
--radiusRoundness of the whole UI; the steps derive from itEverything rounded
--insetGap between a panel’s edge and its itemsMenu, Listbox
Type and motion
--font-*Typeface roles; setup applies --font-ui to the body, the rest you applyUI chrome, prose, headings, code
--ease-*House easing curvesEvery transition

To keep the theme composable:

  • Name by role, never by region. --color-sidebar fits one layout. A sidebar is background-secondary when it’s a flat area beside the content, or panel when it floats over it.
  • Keep steps small and relative. A few points of lightness between layers is enough.

Customise it

Change the values

        @theme {
  --color-accent: oklch(55% 0.2 265);
  --color-accent-foreground: oklch(100% 0 0);
  --radius: 0.25rem;
  --control-height-md: 2.75rem;
}
      

You can make a big difference by only retuning what we already have.

Add a token

Name it by role and give it both themes with light-dark():

        @theme {
  --color-brand: light-dark(oklch(62% 0.19 40), oklch(70% 0.17 40));
}
      

Colours work right away (bg-brand). A custom shadow, text size or radius, such as --shadow-card, also needs registering with tailwind-merge in src/lib/utils/classnames.ts, or cn keeps both conflicting classes:

        const twMerge = extendTailwindMerge({
  extend: { theme: { shadow: ["control", "control-pressed", "panel", "card"] } },
});
      

Change the depth

How raised a control looks comes from four tokens: its edge at rest (--shadow-control), its edge while pressed (--shadow-control-pressed), and how much a pressed button shrinks or moves down (--press-scale, --press-offset). Changing them restyles every button, input and checkbox at once. Three examples:

Flat: no shadow, only the border. Buttons don’t shrink when pressed. Use 0 0 transparent rather than none: Tailwind combines this value with the focus ring into one box-shadow, and none would make the browser drop both.

        @theme {
  --shadow-control: 0 0 transparent;
  --press-scale: 1;
}
      

Raised: a light line along the top, a darker bottom edge and a soft shadow, so controls look like they stand off the page. Pressed buttons move down 1px instead of shrinking. Primary and destructive buttons share the same edge, so the top line shows on them too; keep it faint if that’s too bright on your accent.

        @theme {
  --shadow-control:
    inset 0 1px 0 light-dark(oklch(100% 0 0 / 0.9), oklch(100% 0 0 / 0.06)),
    0 1px 0 light-dark(oklch(0% 0 0 / 0.08), oklch(0% 0 0 / 0.4)),
    0 2px 3px oklch(0% 0 0 / 0.07);
  --shadow-control-pressed: inset 0 1px 1px oklch(0% 0 0 / 0.06);
  --press-scale: 1;
  --press-offset: 1px;
}
      

Embossed: controls have no fill of their own, so they take the colour of whatever is behind them. A thin white outline inside the border makes them look pressed into the page.

        @theme {
  --color-control: transparent;
  --shadow-control:
    inset 0 0 0 1px light-dark(oklch(100% 0 0 / 0.9), oklch(100% 0 0 / 0.08)),
    0 1px 0 oklch(0% 0 0 / 0.07);
  --shadow-control-pressed: inset 0 1px 2px oklch(0% 0 0 / 0.08);
  --press-scale: 1;
  --press-offset: 1px;
}
      

Patterns

Card. A panel with a resting edge; there’s no Card component.

        <section className="rounded-xl border border-border bg-panel shadow-panel">…</section>
      

Inverted region. For a bar or chip that uses the opposite theme (dark in light mode, light in dark mode), flip the colour scheme on its container. Every token inside switches with it, including buttons and borders, so you don’t need any extra colours.

        <div className="bg-background text-foreground scheme-dark dark:scheme-light">…</div>
      

For one that stays dark in both themes, drop dark:scheme-light and give it a fill of its own. Only tokens defined with light-dark() flip, so a custom accent with a single value stays as it is, and tooltips or popovers opened from the region render outside it.

Solid badge. A Badge uses one colour for its fill, ring and text. To make it solid, fill it at full strength and set the text to the background colour.

        <Badge className="[--badge-fill:100%] [--badge-text:var(--color-background)]">New</Badge>
      

The status hues are dark enough in light mode to carry white text, so a solid success or error badge passes WCAG AA too.

Selected toggle. A pressed Toggle gets a light tint by default. To make it solid, set the pressed fill and text colours.

        <Toggle className="[--button-pressed-bg:var(--color-foreground)] [--button-pressed-text:var(--color-background)]">
  Selected
</Toggle>
      

Hover and selected fills. Inside your own components, use bg-foreground/5 for hover and highlighted backgrounds rather than a colour token. It’s translucent like the borders, so it shows on any layer.

Seams. Where two bordered elements meet, drop one side (border-r-0, divide-*). Overlapping translucent lines draw darker.

Gotchas

  • Shadows are baked at build time. Tailwind writes shadow values into the CSS, so change them in @theme. Overriding --shadow-control on an element does nothing; colours can be overridden anywhere.
  • light-dark() only takes colours. Gradients, lengths and percentages that differ per theme need a [data-theme="dark"] rule.
  • Children paint over inset edges. An inset shadow is drawn under the element’s children, so a filled header or row inside a panel hides the panel’s inner rim. Draw the rim on an overlay instead, with relative on the panel: after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_white].
  • Squircles. Corners are round. For squircles, add corner-shape: squircle to * (and round back on .rounded-full) inside @supports (corner-shape: squircle), and raise --radius there too: a squircle reads less round at the same radius. Only Chromium supports it, so other browsers keep round corners.

Previous

Setup

Next

Avatar