Skip to content
Theming

Build a design system, not a pile of values

Tokens in three layers, themes that fill them, and components that only know what a value is for. Change the mode, the brand, or the density, and nothing in a component changes.

The same components, twice

Both panels render the same card with the same markup. The left one reads tokens; the right one has its values written in place. They start out identical. Change the design and see which one follows.

Mode
Brand
Density
components.ts: written against tokens
Admin3 seats leftOverdueDraft

Invite a teammate

They get access to every project in this workspace.

hardcoded.ts: values written in place
Admin3 seats leftOverdueDraft

Invite a teammate

They get access to every project in this workspace.

Three layers of tokens

A design system needs more than a list of colors. Pleat’s Token.make takes any tree of kinds, so the layers are just three trees:

  • Primitives are the palette: color ramps, a spacing scale, radii, type sizes, typefaces, shadows. They are the only layer with literal values, and no component reads them.
  • Semantic tokens say what a value is for: surface, text, textMuted, accent, onAccent, danger, spacing.inset, typography.title. Components read only these.
  • Component tokens are the few knobs one component owns, such as button.radius, so every button can change without every control changing.

Text colors and the backgrounds they sit on come in named pairs: text on surface, onAccent on accent, onDangerSoft on dangerSoft. The file lists them in contrastPairs, so a contrast check can walk them in every mode.

tokens.tsTypeScript
import { Token } from '@pleat/core'

// Every step a color ramp has, light to dark.
export const STEPS = [
  50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
] as const
export type Step = (typeof STEPS)[number]

// One color token per step. Typed with the color kind, so a theme
// can only alias a ramp step to another color.
type Ramp = { readonly [S in Step]: typeof Token.color }

const ramp = (): Ramp =>
  Object.fromEntries(STEPS.map(step => [step, Token.color])) as Ramp

// PRIMITIVES: the raw palette and scales. The only layer with
// literal values, and no component reads it directly.
export const palette = Token.make(
  {
    white: Token.color,
    black: Token.color,
    gray: ramp(),
    brand: ramp(),
    red: ramp(),
    green: ramp(),
    space: {
      1: Token.length,
      2: Token.length,
      3: Token.length,
      4: Token.length,
      5: Token.length,
      6: Token.length,
    },
    radius: {
      sm: Token.length,
      md: Token.length,
      lg: Token.length,
      full: Token.length,
    },
    size: {
      xs: Token.length,
      sm: Token.length,
      md: Token.length,
      lg: Token.length,
      xl: Token.length,
    },
    typeface: { sans: Token.fontFamily, display: Token.fontFamily },
    shadow: {
      soft: Token.value,
      deep: Token.value,
      flat: Token.value,
    },
  },
  { prefix: 'ds-' },
)

// SEMANTIC: what a value is for. Components read only these, so a
// theme can remap them without touching a component.
export const semantic = Token.make(
  {
    color: {
      surface: Token.color,
      surfaceRaised: Token.color,
      surfaceSunken: Token.color,
      text: Token.color,
      textMuted: Token.color,
      border: Token.color,
      borderStrong: Token.color,
      accent: Token.color,
      accentHover: Token.color,
      onAccent: Token.color,
      accentSoft: Token.color,
      onAccentSoft: Token.color,
      danger: Token.color,
      dangerHover: Token.color,
      onDanger: Token.color,
      dangerSoft: Token.color,
      onDangerSoft: Token.color,
      successSoft: Token.color,
      onSuccessSoft: Token.color,
      focus: Token.color,
    },
    elevation: { raised: Token.value },
    spacing: {
      inset: Token.length,
      gap: Token.length,
      controlX: Token.length,
      controlY: Token.length,
    },
    typography: {
      body: Token.length,
      label: Token.length,
      small: Token.length,
      title: Token.length,
    },
    shape: { control: Token.length, surface: Token.length },
    font: { body: Token.fontFamily, display: Token.fontFamily },
  },
  { prefix: 'ds-' },
)

// COMPONENT: the few knobs one component owns, so all buttons can
// change without every control changing.
export const component = Token.make(
  {
    button: {
      radius: Token.length,
      paddingX: Token.length,
      paddingY: Token.length,
    },
    badge: { radius: Token.length, paddingX: Token.length },
  },
  { prefix: 'ds-' },
)

// The part of the palette a customer may set: their ramp and their
// corners. Everything semantic still comes from the mode.
export const brandKit = {
  brand: palette.brand,
  radius: palette.radius,
}

const { color } = semantic

// Text and the backgrounds it sits on. Every mode must keep each
// pair readable, so a contrast check can walk this list.
export const contrastPairs = [
  { text: color.text, background: color.surface },
  { text: color.text, background: color.surfaceRaised },
  { text: color.text, background: color.surfaceSunken },
  { text: color.textMuted, background: color.surface },
  { text: color.textMuted, background: color.surfaceRaised },
  { text: color.onAccent, background: color.accent },
  { text: color.onAccent, background: color.accentHover },
  { text: color.onAccentSoft, background: color.accentSoft },
  { text: color.onDanger, background: color.danger },
  { text: color.onDanger, background: color.dangerHover },
  { text: color.onDangerSoft, background: color.dangerSoft },
  { text: color.onSuccessSoft, background: color.successSoft },
] as const

Themes fill each layer

A theme can cover any tree of tokens, and a value can be another token. That gives each layer its own themes, and each theme a small job:

  • Brands give the primitives values. Harbor is the base; Orchard is Theme.extend(harbor, …) with a new ramp, rounder corners, and a serif for titles.
  • Modes point semantic colors at primitives. Dark and high contrast extend light, so each one lists only what differs.
  • Densities point semantic spacing and type at different steps of the scales.
  • The system theme holds aliases that never change, such as button.radius → shape.control → radius.md.

Global.theme applies each one at an attribute selector. Because an alias resolves wherever it is declared, a brand changes brand[600] and accent follows in every mode, with nothing written twice. Aliases are checked by kind, so pointing surface at a spacing step is a type error.

themes.tsTypeScript
import { Global, Theme } from '@pleat/core'

import {
  STEPS,
  type Step,
  component,
  palette,
  semantic,
} from './tokens.ts'

const { white, black, gray, brand, red, green } = palette
const { space, radius, size, typeface, shadow } = palette

// PRIMITIVE VALUES

// Lightness and a share of the ramp's chroma for each step. Chroma
// eases off toward white and black so every step stays in gamut.
const STOPS: Readonly<Record<Step, readonly [number, number]>> = {
  50: [97.5, 0.06],
  100: [94.5, 0.14],
  200: [89, 0.3],
  300: [81, 0.55],
  400: [71, 0.8],
  500: [62, 1],
  600: [54, 1],
  700: [46, 0.9],
  800: [38, 0.75],
  900: [29, 0.6],
  950: [21, 0.45],
}

// One hue and chroma make a whole ramp.
export const shades = (hue: number, chroma: number) =>
  Object.fromEntries(
    STEPS.map(step => {
      const [lightness, share] = STOPS[step]
      const c = (chroma * share).toFixed(3)
      return [step, `oklch(${lightness}% ${c} ${hue})`]
    }),
  ) as Readonly<Record<Step, string>>

// BRANDS: values for the primitives. A brand is a palette.

export const harbor = Theme.make(palette, {
  white: 'oklch(100% 0 0)',
  black: 'oklch(14% 0.01 260)',
  gray: shades(260, 0.02),
  brand: shades(262, 0.17),
  red: shades(25, 0.19),
  green: shades(150, 0.13),
  space: {
    1: '0.25rem',
    2: '0.5rem',
    3: '0.75rem',
    4: '1rem',
    5: '1.5rem',
    6: '2rem',
  },
  radius: { sm: '4px', md: '6px', lg: '10px', full: '999px' },
  size: {
    xs: '0.75rem',
    sm: '0.8125rem',
    md: '0.9375rem',
    lg: '1.125rem',
    xl: '1.375rem',
  },
  typeface: {
    sans: 'ui-sans-serif, system-ui, sans-serif',
    display: 'ui-sans-serif, system-ui, sans-serif',
  },
  shadow: {
    soft: '0 1px 2px oklch(20% 0.02 260 / 0.08), 0 10px 24px -14px oklch(20% 0.02 260 / 0.3)',
    deep: '0 1px 0 oklch(100% 0 0 / 0.04) inset, 0 12px 28px -12px oklch(0% 0 0 / 0.7)',
    flat: 'none',
  },
})

// A second brand overrides only what makes it different: its hue,
// rounder corners, and a serif for titles.
export const orchard = Theme.extend(harbor, palette, {
  brand: shades(345, 0.15),
  radius: { sm: '8px', md: '12px', lg: '20px' },
  typeface: {
    display: '"Iowan Old Style", Palatino, Georgia, serif',
  },
})

// MODES: semantic colors, as aliases into the palette.

const mode = {
  color: semantic.color,
  elevation: semantic.elevation,
}

export const light = Theme.make(mode, {
  color: {
    surface: gray[50],
    surfaceRaised: white,
    surfaceSunken: gray[100],
    text: gray[900],
    textMuted: gray[600],
    border: gray[200],
    borderStrong: gray[400],
    accent: brand[600],
    accentHover: brand[700],
    onAccent: white,
    accentSoft: brand[100],
    onAccentSoft: brand[800],
    danger: red[600],
    dangerHover: red[700],
    onDanger: white,
    dangerSoft: red[100],
    onDangerSoft: red[800],
    successSoft: green[100],
    onSuccessSoft: green[800],
    focus: brand[500],
  },
  elevation: { raised: shadow.soft },
})

export const dark = Theme.extend(light, mode, {
  color: {
    surface: gray[950],
    surfaceRaised: gray[900],
    surfaceSunken: black,
    text: gray[50],
    textMuted: gray[400],
    border: gray[800],
    borderStrong: gray[600],
    accent: brand[400],
    accentHover: brand[300],
    onAccent: black,
    accentSoft: brand[900],
    onAccentSoft: brand[200],
    danger: red[400],
    dangerHover: red[300],
    onDanger: black,
    dangerSoft: red[900],
    onDangerSoft: red[200],
    successSoft: green[900],
    onSuccessSoft: green[200],
    focus: brand[400],
  },
  elevation: { raised: shadow.deep },
})

// High contrast starts from light and pushes text, borders, and
// accents to the ends of their ramps.
export const highContrast = Theme.extend(light, mode, {
  color: {
    surface: white,
    surfaceSunken: gray[50],
    text: black,
    textMuted: gray[800],
    border: gray[900],
    borderStrong: black,
    accent: brand[800],
    accentHover: brand[950],
    onAccentSoft: brand[950],
    danger: red[800],
    dangerHover: red[950],
    onDangerSoft: red[950],
    onSuccessSoft: green[950],
    focus: black,
  },
  elevation: { raised: shadow.flat },
})

// DENSITIES: semantic spacing and type, as aliases into the scales.

const density = {
  spacing: semantic.spacing,
  typography: semantic.typography,
}

export const comfortable = Theme.make(density, {
  spacing: {
    inset: space[5],
    gap: space[4],
    controlX: space[4],
    controlY: space[3],
  },
  typography: {
    body: size.md,
    label: size.md,
    small: size.sm,
    title: size.xl,
  },
})

export const compact = Theme.extend(comfortable, density, {
  spacing: {
    inset: space[4],
    gap: space[2],
    controlX: space[3],
    controlY: space[2],
  },
  typography: { body: size.sm, label: size.sm, small: size.xs },
})

// SYSTEM: aliases that never change between themes.

export const system = Theme.make(
  { shape: semantic.shape, font: semantic.font, ...component },
  {
    shape: { control: radius.md, surface: radius.lg },
    font: { body: typeface.sans, display: typeface.display },
    button: {
      radius: semantic.shape.control,
      paddingX: semantic.spacing.controlX,
      paddingY: semantic.spacing.controlY,
    },
    badge: { radius: radius.full, paddingX: space[2] },
  },
)

// Each axis is one attribute on a scope's root element.
Global.theme(system, { selector: '[data-ds-mode]' })
Global.theme(harbor, { selector: '[data-ds-brand="Harbor"]' })
Global.theme(orchard, { selector: '[data-ds-brand="Orchard"]' })
Global.theme(light, { selector: '[data-ds-mode="Light"]' })
Global.theme(dark, { selector: '[data-ds-mode="Dark"]' })
Global.theme(highContrast, {
  selector: '[data-ds-mode="HighContrast"]',
})
Global.theme(comfortable, {
  selector: '[data-ds-density="Comfortable"]',
})
Global.theme(compact, { selector: '[data-ds-density="Compact"]' })

Components read only semantic tokens

Here are the two files behind the panels above. They have the same structure. The difference is where the values live. hardcoded.ts has 31 color literals. components.ts has 0.

components.tsTypeScript
import { Recipe, Style, When } from '@pleat/core'

import { component, semantic } from './tokens.ts'

const {
  color,
  elevation,
  spacing,
  typography,
  shape,
  font,
} = semantic
const { button: buttonToken, badge: badgeToken } =
  component

const focusRing = Style.empty.pipe(
  Style.when(When.focusVisible, {
    outline: `2px solid ${color.focus}`,
    outlineOffset: '2px',
  }),
)

// The root of a scope paints its own surface.
export const scopeRoot = Style.make({
  backgroundColor: color.surface,
  color: color.text,
  fontFamily: font.body,
  fontSize: typography.body,
  lineHeight: 1.5,
})

export const button = Recipe.make({
  name: 'Button',
  base: Style.make({
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    paddingBlock: buttonToken.paddingY,
    paddingInline: buttonToken.paddingX,
    borderRadius: buttonToken.radius,
    border: '1px solid transparent',
    fontFamily: 'inherit',
    fontSize: typography.label,
    fontWeight: 600,
    lineHeight: 1.25,
    cursor: 'pointer',
  }).pipe(Style.merge(focusRing)),
  variants: {
    tone: {
      Primary: Style.make({
        backgroundColor: color.accent,
        color: color.onAccent,
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: color.accentHover,
        }),
      ),
      Secondary: Style.make({
        backgroundColor: color.surfaceRaised,
        color: color.text,
        borderColor: color.borderStrong,
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: color.surfaceSunken,
        }),
      ),
      Danger: Style.make({
        backgroundColor: color.danger,
        color: color.onDanger,
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: color.dangerHover,
        }),
      ),
    },
  },
  defaults: { tone: 'Secondary' },
})

export const input = Style.make({
  width: '100%',
  paddingBlock: spacing.controlY,
  paddingInline: spacing.controlX,
  borderRadius: shape.control,
  border: `1px solid ${color.borderStrong}`,
  backgroundColor: color.surfaceRaised,
  color: color.text,
  fontFamily: 'inherit',
  fontSize: typography.body,
}).pipe(
  Style.when(When.placeholder, {
    color: color.textMuted,
  }),
  Style.merge(focusRing),
)

export const label = Style.make({
  display: 'grid',
  gap: spacing.controlY,
  fontSize: typography.small,
  fontWeight: 600,
  color: color.textMuted,
})

export const card = Style.make({
  display: 'grid',
  gap: spacing.gap,
  padding: spacing.inset,
  borderRadius: shape.surface,
  border: `1px solid ${color.border}`,
  backgroundColor: color.surfaceRaised,
  boxShadow: elevation.raised,
})

export const cardTitle = Style.make({
  margin: 0,
  fontFamily: font.display,
  fontSize: typography.title,
  fontWeight: 600,
  lineHeight: 1.2,
})

export const cardBody = Style.make({
  margin: 0,
  color: color.textMuted,
})

export const badge = Recipe.make({
  name: 'Badge',
  base: Style.make({
    display: 'inline-flex',
    paddingBlock: 2,
    paddingInline: badgeToken.paddingX,
    borderRadius: badgeToken.radius,
    fontSize: typography.small,
    fontWeight: 600,
  }),
  variants: {
    tone: {
      Neutral: {
        backgroundColor: color.surfaceSunken,
        color: color.textMuted,
      },
      Accent: {
        backgroundColor: color.accentSoft,
        color: color.onAccentSoft,
      },
      Danger: {
        backgroundColor: color.dangerSoft,
        color: color.onDangerSoft,
      },
      Success: {
        backgroundColor: color.successSoft,
        color: color.onSuccessSoft,
      },
    },
  },
  defaults: { tone: 'Neutral' },
})
hardcoded.tsTypeScript
import { Recipe, Style, When } from '@pleat/core'

// The same components with the values written in
// place. They look identical until the design changes.

const focusRing = Style.empty.pipe(
  Style.when(When.focusVisible, {
    outline: '2px solid #4a81eb',
    outlineOffset: '2px',
  }),
)

export const scopeRoot = Style.make({
  backgroundColor: '#f6f7f8',
  color: '#282c31',
  fontFamily: 'ui-sans-serif, system-ui, sans-serif',
  fontSize: 15,
  lineHeight: 1.5,
})

export const button = Recipe.make({
  name: 'Button',
  base: Style.make({
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    paddingBlock: 12,
    paddingInline: 16,
    borderRadius: 6,
    border: '1px solid transparent',
    fontFamily: 'inherit',
    fontSize: 15,
    fontWeight: 600,
    lineHeight: 1.25,
    cursor: 'pointer',
  }).pipe(Style.merge(focusRing)),
  variants: {
    tone: {
      Primary: Style.make({
        backgroundColor: '#3368d0',
        color: '#ffffff',
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: '#2452ac',
        }),
      ),
      Secondary: Style.make({
        backgroundColor: '#ffffff',
        color: '#282c31',
        borderColor: '#9ca2ac',
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: '#ebedef',
        }),
      ),
      Danger: Style.make({
        backgroundColor: '#c52b30',
        color: '#ffffff',
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: '#a21820',
        }),
      ),
    },
  },
  defaults: { tone: 'Secondary' },
})

export const input = Style.make({
  width: '100%',
  paddingBlock: 12,
  paddingInline: 16,
  borderRadius: 6,
  border: '1px solid #9ca2ac',
  backgroundColor: '#ffffff',
  color: '#282c31',
  fontFamily: 'inherit',
  fontSize: 15,
}).pipe(
  Style.when(When.placeholder, { color: '#686f7b' }),
  Style.merge(focusRing),
)

export const label = Style.make({
  display: 'grid',
  gap: 12,
  fontSize: 13,
  fontWeight: 600,
  color: '#686f7b',
})

export const card = Style.make({
  display: 'grid',
  gap: 16,
  padding: 24,
  borderRadius: 10,
  border: '1px solid #d8dbdf',
  backgroundColor: '#ffffff',
  boxShadow:
    '0 1px 2px rgb(30 35 45 / 0.08), ' +
    '0 10px 24px -14px rgb(30 35 45 / 0.3)',
})

export const cardTitle = Style.make({
  margin: 0,
  fontFamily: 'ui-sans-serif, system-ui, sans-serif',
  fontSize: 22,
  fontWeight: 600,
  lineHeight: 1.2,
})

export const cardBody = Style.make({
  margin: 0,
  color: '#686f7b',
})

export const badge = Recipe.make({
  name: 'Badge',
  base: Style.make({
    display: 'inline-flex',
    paddingBlock: 2,
    paddingInline: 8,
    borderRadius: 999,
    fontSize: 13,
    fontWeight: 600,
  }),
  variants: {
    tone: {
      Neutral: {
        backgroundColor: '#ebedef',
        color: '#686f7b',
      },
      Accent: {
        backgroundColor: '#e2edff',
        color: '#193d85',
      },
      Danger: {
        backgroundColor: '#ffe5e1',
        color: '#7d0f16',
      },
      Success: {
        backgroundColor: '#e3f1e5',
        color: '#0b5024',
      },
    },
  },
  defaults: { tone: 'Neutral' },
})

The hardcoded version is not wrong today. It costs more each time the design moves:

When the design changesHardcodedTokens
Add dark modeGive each of the 31 color literals a second value under a dark condition, in every component.One mode theme of semantic aliases. No component changes.
Ship a second brandFind every blue, its hover, and its soft tint, then fork the components or thread a brand prop through them.One Theme.extend with a new ramp, radii, and title face.
Add a compact densityChange every padding, gap, and font size, or add a size prop to each component.One density theme that points spacing and type at smaller steps.
A dark panel on a light pageDuplicate the components, or override them with descendant selectors that fight specificity.Three attributes on the panel.
Check contrastRead every style to work out which text sits on which background.Walk contrastPairs in each mode.

Scoped and nested themes

A scope is an element with the three attributes. Everything under it reads that scope’s values, and scopes nest: a dark panel on a light page is a scope inside a scope.

The page scope below follows the controls at the top. Inside it, a panel inverts the mode and turns compact, and inside that, a callout inverts again and switches brand. One component definition renders all three.

Light · Harbor · Comfortable
New
Dark · Harbor · Compact
New
Light · Orchard · Compact
New

Set every axis on a scope

Custom properties resolve var() where they are declared, and descendants inherit the result. If a nested scope set only a new brand, the semantic accent it inherited would still hold the outer brand’s color. Setting all three attributes on every scope re-declares every alias there, so they resolve against the scope’s own values. scopeAttributes makes that the only way to write one.

scope.tsTypeScript
import { Schema } from 'effect'
import type { Attribute, HtmlBuilder } from 'foldkit/html'

// The three axes a scope chooses. They live in the Model like any
// other choice.
export const Mode = Schema.Literals([
  'Light',
  'Dark',
  'HighContrast',
])
export type Mode = typeof Mode.Type

export const Brand = Schema.Literals(['Harbor', 'Orchard'])
export type Brand = typeof Brand.Type

export const Density = Schema.Literals(['Comfortable', 'Compact'])
export type Density = typeof Density.Type

export const Scope = Schema.Struct({
  mode: Mode,
  brand: Brand,
  density: Density,
})
export type Scope = typeof Scope.Type

// A scope sets all three on one element. Aliases resolve where they
// are declared, so a nested scope that changed only its brand would
// leave the semantic colors it inherited pointing at the old one.
export const scopeAttributes = <Message>(
  h: HtmlBuilder<Message>,
  scope: Scope,
): ReadonlyArray<Attribute<Message>> => [
  h.DataAttribute('ds-mode', scope.mode),
  h.DataAttribute('ds-brand', scope.brand),
  h.DataAttribute('ds-density', scope.density),
]

// A panel that reads as the opposite of its surroundings.
export const inverted = (scope: Scope): Scope => ({
  ...scope,
  mode: scope.mode === 'Dark' ? 'Light' : 'Dark',
})

Themes from outside the program

Some themes arrive at runtime: a customer’s brand from a settings API, or a palette a language model proposes. Theme.decodePartial checks each value it is given against its token’s kind and fails with a SchemaError that names the path, so a bad value never becomes CSS. It is the same closed-world idea as generative interfaces: outside input can choose values, never write rules.

decode.tsTypeScript
import { Style, Theme } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { brandKit } from './tokens.ts'

// Values from a settings form, an API, or a language model are
// decoded against each token's kind, never trusted. Any of them may
// be left out; the scope's own brand supplies the rest.
export const decodeBrand = (input: unknown) =>
  Theme.decodePartial(Theme.empty, brandKit, input)

// A decoded theme is a list of checked custom properties, so one
// element can carry it inline, in the same css() call as its styles.
// Every alias below it follows.
export const branded = (
  theme: Theme.Theme,
  ...styles: ReadonlyArray<Style.Style>
) => css(...styles, ...Theme.bindings(theme))

Edit the JSON or pick an example. A valid brand is applied inline to one scope, and every semantic alias below it follows, in whichever mode the controls at the top have chosen.

Decoded and applied to this scope
Admin3 seats leftOverdueDraft

Invite a teammate

They get access to every project in this workspace.

Contrast pairs

Every pair in contrastPairs, rendered in the scope chosen at the top. Switch modes to see each pair hold up, or not.

Aatext on surface
Aatext on surfaceRaised
Aatext on surfaceSunken
AatextMuted on surface
AatextMuted on surfaceRaised
AaonAccent on accent
AaonAccent on accentHover
AaonAccentSoft on accentSoft
AaonDanger on danger
AaonDanger on dangerHover
AaonDangerSoft on dangerSoft
AaonSuccessSoft on successSoft