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.
Invite a teammate
They get access to every project in this workspace.
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.
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 constThemes 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.
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.
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' },
})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 changes | Hardcoded | Tokens |
|---|---|---|
| Add dark mode | Give 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 brand | Find 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 density | Change 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 page | Duplicate the components, or override them with descendant selectors that fight specificity. | Three attributes on the panel. |
| Check contrast | Read 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.
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.
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.
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.
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.