Eight selectors picking from one palette against the color in the middle: a segment per selector, a ring per color, the picked color filled

A tour of Design Book

Most design systems store answers: a hex picked here, a spacing chosen there. I kept wanting to store how the answer was picked instead: the highest-contrast text on a surface, the next bigger gap in a scale, the most vivid color that still passes WCAG. Change an input and every token that depends on it is worked out again, contrast included.

TypeScript · ESM

A web of decisions, not a list of values.

A book keeps its tokens in scopes, named groups like brand or ui; brand.primary is the token primary in brand.

Each token is a node and each reference an edge. Change an input and the book works out again exactly the tokens that depend on it, and nothing else.

The diagram on the right is a small demo book: a brand color, a ramp made from it, a spacing scale and the UI decisions on top, drawn by the built-in SVGRenderer. Change the brand color or flip the ramp and watch it redraw.

References instead of copies.

This part you already know from Figma variables and CSS custom properties. A hex code pasted in two places is two decisions, and sooner or later they drift apart; ref() stores where a value comes from, the key and not the value, so changing the source changes everything that points at it. Refs chain and cross scopes, and in CSS the link stays a var().

It is the foundation, not the point. A reference can only say use that one; everything after this section is about the tokens that decide which one.

brand.set('primary',
  color('#3c61a0'));

ui.set('link',
  ref('brand.primary'));
ui.set('focus', ref('ui.link'));
ui.set('border', ref('ui.link'));

          

The reason is the token.

A custom property can point at another, and CSS's contrast-color() picks black or white. But CSS cannot choose from your palette by your rule. In Design Book the rule is the token: it documents the decision and makes it again whenever an input changes.

On the right the surface never sits still. The text, the loudest ink that still reads, picks again every time.

Rules come in two kinds. Transforms change a value, like color-mix() and calc(), only more. Selectors route: they pick one member of a scope, like mostVivid here.

const ink = book.addScope('ink');
ink.set('black',  color('#0d0a03'));
ink.set('paper',  color('#fffaed'));
ink.set('blue',   color('#172c4f'));
ink.set('red',    color('#de5331'));
ink.set('yellow', color('#dab43d'));
ink.set('green',  color('#479549'));

// the reason, not the answer: the most
// vivid ink that still reads (4.5:1)
ui.set('text', mostVivid(ref('ink'), {
  readableOn: ref('ui.surface'),
}));

// set ui.surface to anything:
// the text decides again

Kinds of decision

A selector takes a scope of candidates and returns the member that fits its rule, and picks again whenever a member changes, is added or is removed. These come built in.

bestContrastWith

bestContrastWith( target, scope, { direction?, readableOn?, not? } )
  • color WCAG ratio
  • size value ratio
  • text style font-size ratio

Change either ink and every contrast is checked again.

The member with the highest contrast against a target: the WCAG ratio for colors, the ratio between two values for sizes and text styles, so a heading can be the style that stands out most from the body. The check is part of the rule, so you don't redo it after every redesign.

const ink = book.addScope('ink');
ink.set('a', color('#172c4f'));
ink.set('b', color('#f3d36b'));

// on every surface: whichever ink reads best
ui.set('label', bestContrastWith(
  ref('ui.surface'), ref('ink'),
))

closest

closest( target, scope, { direction?, upTo?, minDistance?, readableOn?, not? } )
  • color OKLab distance
  • size value
  • text style font size

Work freely, land on your system.

Pick a color by eye, take a size from a mockup, pull a brand color out of a partner's logo: closest snaps it to the nearest value your design system actually has, so the freedom never leaks a one-off into the system. Colors are compared by distance in OKLab, so colors that look alike are treated alike; sizes and text styles by value, so the 13px from the mockup lands on your 12px.

const space = book.addScope('space');
space.set('xs', px(4));   space.set('s', px(8));
space.set('m', px(12));   space.set('l', px(16));
space.set('xl', px(24));  space.set('2xl', px(32));
space.set('3xl', px(48));

// any value: a guess, a mockup, a slider
ui.set('gap', closest(px(13), ref('space')));

// any color: picked by eye, out of a logo
ui.set('accent', closest(color('#7f4dc4'), ref('palette')));

furthestFrom

furthestFrom( target, scope, { maxDistance?, readableOn?, not? } )
  • color OKLab distance
  • size value
  • text style font size

Furthest from what is already there.

The mirror of closest: the member that looks least like the target. An accent that stands apart from the surface, a display size far from the body text. Colors by OKLab distance, sizes and text styles by value. maxDistance keeps it in reach: the furthest member that is no further than that.

// the palette color that looks least like the target
ui.set('accent', furthestFrom(
  ref('ui.surface'),
  ref('palette'),
))

minContrastWith

minContrastWith( target, scope, { minContrast?, readableOn?, not? } )
  • color WCAG ratio
  • size value ratio
  • text style font-size ratio

As subtle as possible while still readable.

The lowest contrast that still reaches a ratio. For colors that is WCAG (4.5 by default): hairlines, captions and dim labels that pass the next audit without anyone checking them by hand. It works the other way too: a hairline, a divider or a ruled background at a ratio around 1.2 stays faint on any surface but never disappears. For sizes and text styles it is the ratio between two values: the smallest heading that still reads as bigger than the body.

// text that just passes AA
ui.set('caption', minContrastWith(
  ref('ui.surface'), neutral,
  { minContrast: 4.5 },
));

// a line that is faint, but always there
ui.set('hairline', minContrastWith(
  ref('ui.surface'), neutral,
  { minContrast: 1.2 },
));

// the smallest heading that still reads as bigger
ui.set('heading', minContrastWith(
  ref('type.body'), ref('type'),
  { minContrast: 1.25, direction: 'larger' },
));

Field notes

Twelve walks along the river, with maps. The body is 16px.

minContrast
ratio 1.2 · hairline
ratio 3 · AA large
ratio 4.5 · AA
ratio 7 · AAA

nextLarger · nextSmaller

nextLarger|nextSmaller( target, scope, { minDistance?, not? } ) = closest( …, { direction } )
  • size value
  • text style font size

Spacing and type that know their scale.

Steps to the next member up or down, in px, rem, ms or whatever unit the scale uses, or to the next text style by its font size. Add a step to the scale and every gap, padding, duration and heading that referred to it moves along.

ui.set('gap', nextLarger(
  ref('space.m'),
  space,
  { minDistance: 6 },
))
nextSmaller —
target 12px
nextLarger —

mostVivid · leastVivid

mostVivid|leastVivid( scope, { lightness?, readableOn?, not? } )
  • color OKLCH chroma

Your accent is probably already in your palette.

The two ends of the chroma axis. mostVivid finds the loudest member, usually the accent, which is handy when the set is generated and you can't know in advance which step that will be. leastVivid finds the most neutral one among your brand colors. Near black and white no color carries much chroma, so lightness: [min, max] compares only the members in the band you mean. Both can also be limited to colors that read on a given surface.

ui.set('accent',  mostVivid(brand))
ui.set('surface', leastVivid(brand))

// only among the mid tones
ui.set('accent', mostVivid(brand, {
  lightness: [0.4, 0.8],
}))
lightness
0–1

lightest · darkest

lightest|darkest( scope, { readableOn?, readableContrast?, not? } )
  • color OKLCH lightness

The ends of the scale, wherever they end up.

The members with the highest and lowest OKLCH lightness. Make your surface lightest and your ink darkest, and they still land on the right colors after the palette is generated again. Add readableOn to only consider members that read on that surface.

ui.set('surface', lightest(brand))
ui.set('ink',     darkest(brand))

// the lightest member that still reads on the page
ui.set('soft-ink', lightest(brand, {
  readableOn: ref('ui.surface'),
}))

random

random( scope, { type, seed?, not? } )
  • any filtered by type

Random, but it stays put.

Picks a member of a scope at random. Each token keeps its own seed, so the value stays the same across re-resolves and machines until you reshuffle on purpose: per release, per user or per environment. type limits the pool to colors, dimensions or strings.

ui.set('accent',
  random(brand, { type: 'color' }))

ui.set('promo-bg', random(brand, {
  type: 'color',
  seed: 'spring-2026',
}))

nth

nth( scope, index, { not? } )
  • any by position

Pick by position instead of by name.

When a ramp or a scale generates your tokens, the order carries the meaning: lightest first, darkest last; smallest first, largest last. nth reads that order directly, which makes it a handy way to move along a color ramp: the surface at one end, the text at the other, the muted tone in the middle, whatever the ramp is regenerated to. Integers work like Array.at() (-1 is the last member), and fractions between 0 and 1 pick a relative position, so 0.5 is the midtone however many stops there are. (1.0 counts as the integer 1; use -1 for the last member.)

// a scale generated smallest → largest
ui.set('hairline', nth(ref('space'), 0))   // smallest
ui.set('section',  nth(ref('space'), -1))  // largest
ui.set('gap',      nth(ref('space'), 0.5)) // the middle

// light and dark: the same rules, the ramp written
// the other way round
const steps = mode === 'dark' ? [...ramp].reverse() : ramp;
steps.forEach((c, i) => scale.set(`${i}`, color(c)));
ui.set('bg',     nth(ref('scale'), 0));
ui.set('text',   nth(ref('scale'), -1));
ui.set('muted',  nth(ref('scale'), 0.6));
ui.set('border', nth(ref('scale'), 0.15));

Field notes

Twelve walks along the river

indexfrom endkeyvalue

index

sibling

sibling( ref, scope?, steps?, { toward?, wrap?, not? } )
  • any along the scale it comes from

One step lighter than whatever the surface is.

Steps through a scale, relative to a token: +1 is the next member, -1 the previous one. Hover and pressed states follow the surface instead of hard-coding a second shade. The scale is the token's own scope, or the one you name (ref('blue')), or wherever source leads (more on that further down); it is never guessed. Steps stop at the ends, or go round with wrap; with toward a step goes in the direction of another member, so a hover steps toward the page, lighter on a light one and darker on a dark one.

// keys from scaleNames(9, 'creatures'):
// tardigrade, ant, beetle, … cat, … whale
ui.set('gap',       ref('space.cat'))
ui.set('gap-loose', sibling(ref('space.cat'), 1)) // wolf
ui.set('gap-tight', sibling(ref('space.cat'), -1)) // hamster

ui.set('surface',       ref('gray.g100'))
ui.set('surface-hover', sibling(ref('gray.g100'), 1))
ui.set('surface-press', sibling(ref('gray.g100'), 2))
Click a tile to move the anchor.
Click a row to move the anchor.
#keyvalue

Only readable colors.

Every color selector except the two contrast ones takes readableOn and readableContrast (4.5 by default). Members that don't reach the ratio against that backdrop are dropped before ranking, so mostVivid returns the loudest color that still reads and closest the nearest readable one. If nothing passes, the token throws an error instead of quietly returning an unreadable color. The backdrop is a reference, so switching themes runs the filter again.

ui.set('link', mostVivid(brand, {
  readableOn:  ref('ui.surface'),
  readableContrast: 4.5,
}));
Faded members fail the ratio and never reach the ranking.

A full scale from one color.

Pick a brand color and ramp() returns the matching stop of a tonal scale copied from a design system you choose. It uses dittotones, which transfers the lightness and chroma curves of established palettes onto any hue: Tailwind, Radix, or masters of your own, even scopes of the book. One call per stop, and they all update when the brand color changes.

Register ramps you drew yourself and new colors get their curve. It works best with many masters across your hues (at least two), all the same length and running light to dark.

import { radixRamps } from 'dittotones/ramps/radix';

book.registerRamps('radix', radixRamps);
brand.set('primary', color('#3c61a0'));

const palette = book.addScope('palette');
for (const shade of ['1', '2', …, '12']) {
  palette.set(shade, ramp(ref('brand.primary'),
    { shade, from: 'radix' }));
}
brand.primary

Naming the steps.

Generated colors still need keys. scaleNames() takes them from a naming convention (50 … 950, t-shirt sizes, an intensity ladder, 100 objects by size) and fits it to however many steps you have, keeping the ends and the center in place. If the count changes, run it again and every step gets a new, evenly spread name.

Try every scheme in the naming playground →

Sorted scopes.

Give a scope an order and it stays sorted by value, name or type, or a combination of them. Colors are sorted through colorsort-js for a perceptually smooth sequence, dimensions by size. Every renderer and every positional selector (nth, nextLarger) reads the same order, and the scope re-sorts as soon as a value changes. Child scopes inherit their parent's order unless they set their own.

// colors, sizes and words, inserted in any order
const ui = book.addScope('ui', {
  order: [{ by: 'value' }],  // colors perceptual, sizes by size
});

ui.setOrder([{ by: 'name' }]);
ui.setOrder([
  { by: 'type', priority: ['color', 'dimension', 'string'] },
  { by: 'value' },           // then each group by value
]);

// your own: colors by lightness alone
book.registerOrderer('color', (entries) =>
  [...entries].sort((a, b) => L(a.resolved) - L(b.resolved)));
#keytypevalue

Inherit, and override what changes.

When two variants live side by side, one scope can extend another. Every token carries over unless you redefine it, and the graph keeps track: change a token in the parent and everything that inherits it is recomputed. The editor dims inherited rows, so only the changes stand out. A full reference keeps pointing at the key it names; a relative one reads from the inheriting scope, and inherited reads the value a scope overrides. Once a theme has to change root values across the whole system, use layers instead.

const light = book.addScope('light');
light.set('bg',      color('#fffaed'));
light.set('text',    color('#0d0a03'));
light.set('primary', color('#3c61a0'));
light.set('accent',  color('#de5331'));
light.set('muted',   color('#908b7f'));

// dark inherits all five, overrides two
const dark = book.addScope('dark', {
  extends: 'light',
});
dark.set('bg',   color('#0d0a03'));
dark.set('text', color('#fffaed'));
dark extends light
bg color('#0d0a03')
text color('#fffaed')
primary inherit → #3c61a0
accent inherit → #de5331
muted inherit → #908b7f
Dimmed rows are inherited.

Relative refs.

Leave the scope off, as in ref('.bg'), and the ref reads that key in whichever scope reads the token, much like this in a method, or a CSS var() that is looked up on the element using it.

That is what makes extending scopes cheap. A variant overrides one value, and every inherited token that reads it relatively follows; nothing you already wrote has to be written again. Base and variant end up side by side, as --button-hover and --button-danger-hover.

It also makes a scope speak about itself. A button's text can be bestContrastWith(ref('.bg'), brand): the best contrast to its own background, whichever button it ends up in.

const btn = book.addScope('button');
btn.set('bg', ref('brand.primary'));
btn.set('fg', bestContrastWith(
  ref('.bg'), brand));
btn.set('hover', darken(ref('.bg'),
  { amount: 0.15 }));

// one root; fg and hover follow
book.addScope('button-danger', {
  extends: 'button',
}).set('bg', ref('brand.error'));

Two trees, three words.

A token sits in two trees. Its scope may extend another scope, and its value may refer to another token. Design Book never moves through either on its own; three words do it on purpose.

inherited climbs the scope tree: the same key, one scope up. source climbs the value tree: the token a reference refers to, up to where the value is written. refersTo comes back down it: the steps of a scale that refer to a token, so a rule can name the role, brand.ink, rather than the step that holds it.

// the scope tree
const card = book.addScope('card');
const inverted = book.addScope('card-inverted', { extends: 'card' });
card.set('surface', ref('palette.white'));

// up: card's surface, one scope up
inverted.set('border', inherited(ref('.surface')));

// the value tree
brand.set('ink', ref('palette.navy'));
scale.set('900', ref('brand.ink'));

// up: the step after palette.navy, where ink is written
ui.set('accent', sibling(source(ref('brand.ink'), 'root'), 1));

// down: leave out scale.900, the step that holds ink
ui.set('text', bestContrastWith(ref('.bg'), ref('scale'), {
  not: [refersTo(ref('brand.ink'))],
}));

Up the scope tree: inherited.

inherited climbs the scope tree. A scope that overrides interaction cannot read ref('.interaction') to work out the new value: that is the token it is defining, and the book refuses the cycle. inherited(ref('.interaction')) reads the one it replaces, the value it would have inherited; inherited(ref, 2) two scopes up, inherited(ref, 'root') the top. Here a variant's hover is its parent's interaction color, two steps lighter along the blue ramp.

const base = book.addScope('base');
base.set('background', ref('blue.50'));
base.set('interaction', ref('blue.600'));

const hover = book.addScope('base-hover', {
  extends: 'base',
});
// two steps lighter than the one it replaces
hover.set('interaction', sibling(
  inherited(ref('.interaction')), ref('blue'), -2,
));

Back to the value: source.

source climbs the other tree, the references. card.interaction = ref('blue.600'), so source(ref('.interaction')) is blue.600, the token it refers to; source(ref, 'root') follows the references to the token where the value is written.

It is how you move relative to a value in the scale it was generated in: a step lighter, a size up, without looking up the next shade and typing it in. When you know the scale, name it: sibling(ref('.interaction'), ref('blue'), -2). When a rule is shared by themes whose colors come from different ramps, let it go back to wherever the value comes from: one rule, a blue button and a green one.

const button = book.addScope('button');
button.set('interaction', ref('blue.500'));
// two steps lighter, in whichever ramp
// the interaction color comes from
button.set('hover', sibling(
  source(ref('.interaction'), 'root'), -2,
));

book.addScope('button-green', { extends: 'button' })
  .set('interaction', ref('green.500'));

Back down the value: refersTo.

refersTo is source the other way round. One of a scale's dark steps is often the brand's ink: scale.800 = ref('brand.ink'). To keep body text off the ink, a rule could leave out scale.800, but that names a step, not what you mean. refersTo(ref('brand.ink')) names whichever step of the pool refers to the ink.

In a not list it leaves out every step that does. As a sibling anchor or toward it needs exactly one, and says which steps it found when there are more. Move the ink to another step and the rule follows it.

const brand = book.addScope('brand');
brand.set('ink', color('#182a59'));

const scale = book.addScope('scale');
// … 50 to 700, then the ink as a step
scale.set('800', ref('brand.ink'));

ui.set('bg', ref('scale.50'));
// the most contrast, but never the ink
ui.set('text', bestContrastWith(
  ref('.bg'), ref('scale'),
  { not: [refersTo(ref('brand.ink'))] },
));
brand.ink is step

Across a whole system: layers.

Every shop in a chain shares the brand, but the old-town store has its own accent. Write the shop's system once as a layer; each store is another layer that only holds what's different about it, here a single color. Composing a book from the stack puts the override into the same graph as everything else, so every ref and selector built on that color is recomputed.

const system = layer('store-system', (book) => {
  const brand = book.addScope('brand');
  brand.set('highlight', color('#3c61a0'));
  // … ink, paper …
  const ui = book.addScope('ui');
  ui.set('link',  ref('brand.highlight'));
  ui.set('hover', darken(ref('ui.link'),
    { amount: 0.2 }));
  ui.set('tint', colorMix(ref('ui.link'),
    ref('brand.paper'), { ratio: 0.85 }));
});

// a store is only what sets it apart
const oldTown = layer('old-town', {
  brand: { highlight: color('#de5331') },
});
const harbour = layer('harbour', {
  brand: { highlight: color('#479549') },
});

composeBook('old-town', [system, oldTown]);
old-town on store-system
brand.highlight color('#de5331') old-town
ui.link ref → #de5331
ui.hover darken → #a53b22
ui.tint colorMix → #f0d4c1
brand.ink store-system → #0d0a03
brand.paper store-system → #eee9dc
Dimmed rows are inherited.

Type styles as one token.

A type style is a set of properties: family, size, weight, line-height, tracking. typography() makes it one token, the W3 typography composite, so it sits in a scope like everything else. Its fields are tracked in the graph like any rule: a ref() carries the whole style, ref('type.heading.fontSize') reads one field. Renderers write a variable per field plus a CSS class for the browser, and a $type: 'typography' token for W3 Design Tokens.

type.set('heading', typography({
  fontFamily:    ref('fonts.serif'),
  fontSize:      rem(2.5),
  fontWeight:    '700',
  lineHeight:    1,
  letterSpacing: '-0.02em',
}));

// a variant reads the rest from its base, live
type.set('hero', variant(
  ref('type.heading'),
  { fontSize: rem(4) },
));

// one field, anywhere
callout.set('size', ref('type.heading.fontSize'));
Type styles travel whole.
Variants follow.

Value transforms.

Operations that take an input and apply a formula, for the small decisions like a hover shade, a step in a scale or a timing curve, without leaving the graph.

Mixing two colors.

colorMix() interpolates between two colors, and the color space decides the path: rgb goes straight through the cube, lab and oklab follow perceptual lines, lch, oklch and hsl rotate around the hue. Same two colors, very different middles. Any color-mix() space works, and the CSS renderer writes the matching color-mix().

ui.set('tint', colorMix(
  ref('brand.primary'),
  ref('brand.surface'),
  { ratio: 0.5, colorSpace: 'oklch' }
));

Many outputs from one book.

The same book renders to CSS variables, JSON and the W3 Design Tokens format out of the box. A custom renderer is one call, book.registerRenderer(name, fn), and about twenty lines of TypeScript turn the graph into a Tailwind config, Swift extensions, SwiftUI, Kotlin or whatever JSON dialect you need.

The custom Tailwind renderer in full
// A renderer is a function from `book` to a string. That's the whole API.
function tailwindRenderer(book) {
  const colors = {}, spacing = {};
  const isColor     = (s) => parse(s) != null;
  const isDimension = (s) => /^-?\d+(\.\d+)?[a-z%]+$/i.test(s);

  for (const scope of book.getAllScopes()) {
    for (const k of scope.getAllKeys()) {
      const v = scope.resolve(k);
      const key = `${scope.name}-${k}`;
      if (isColor(v))     colors[key]  = v;
      else if (isDimension(v)) spacing[key] = v;
    }
  }
  return `module.exports = { theme: { extend: ${JSON.stringify({ colors, spacing }, null, 2)} } }`;
}

// Register it under any name; it works like the built-ins.
book.registerRenderer('tailwind', tailwindRenderer);
const config = book.render('tailwind');

Your own rules and libraries.

The built-in selectors are a starting set. You can register any function that takes the token's resolved arguments (scopes arrive as scopes) and its options, and returns a value. Tokens that call it work like the built-ins: dependencies are tracked, cycles refused, values recomputed.

A custom function

Here I register a new selector, warmest, that picks the member leaning hardest towards orange: its chroma times how close its hue is to 50°. It runs on this page against the palette above.

// Resolved args come first, the token's options last.
book.registerFunction('warmest', (scope) => {
  let best = null, bestScore = -Infinity;
  for (const key of scope.getAllKeys()) {
    const hex = scope.resolve(key);
    const { c = 0, h = 0 } = oklch(hex);
    const score = c * Math.cos((h - 50) * Math.PI / 180);
    if (score > bestScore) { bestScore = score; best = hex; }
  }
  return best;
});

ui.set('warm', createFunctionToken('warmest', [values]));

warmest(values) → —

toward hue 50°

Integration with a library

Poline generates a ramp between anchor colors. Put it straight into a scope and the selectors work on it like on any other palette.

// two anchors across the wheel: a straight line
// through its centre, seven colors along it
const poline = new Poline({
  anchorColors: [[12, .85, .75], [192, .85, .75]],
  numPoints: 5,
});
const ramp = book.addScope('ramp');
poline.colorsCSS.forEach((c, i) => ramp.set(`s${i}`, color(c)));

ui.set('border', minContrastWith(
  ref('ui.surface'), ramp, { minContrast: 3 },
));

minContrastWith( ui.surface, ramp, { minContrast: 3 } ) → —

$ npm i design-book