Design Book

A reactive engine for design decisions

The rules behind a design system, not the values.

Design systems are normally stored as fixed answers — a hex picked here, a spacing chosen there. Design Book stores how the answer is decided: 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 dependent re-decides itself — accessibility included.

$ npm i design-book

TypeScript · ESM · two dependencies (culori, dittotones).

Pull one thread. The system follows.

A token is a node. A rule is an edge. References, computed values and scope-iterating selectors are all tracked, so when an input changes the library knows exactly what to recompute — straight away, or all at once with flush() in batch mode. Cycles are refused at write time; watch(key, fn) calls you back when a value changes.

The diagram on the right is the actual graph for the book that powers this page, rendered with the built-in SVGRenderer. Try the controls — every visible edge and node updates from the same source of truth.

Kinds of decision

Thirteen built-in selectors. Each one takes a scope of candidates and returns the member that fits a rule — re-deciding whenever a member changes, joins or leaves.

bestContrastWith

bestContrastWith( target, scope, { not? } )

Change the brand color. Every contrast ratio re-validates.

The most readable color in a palette against any background — WCAG-measured. Compliance becomes a rule the system enforces, not a checklist re-run after every redesign.

One palette · six surfaces · text auto-picked.
ui.set('label', bestContrastWith(
  ref('ui.surface'),
  brand,
))

closestColor

closestColor( target, scope, { readableOn?, minContrast?, not? } )

Off-palette colors don't sneak in. They snap to the nearest member you actually own.

A perceptual neighbour-search in OKLab — so colors that look the same get treated the same. Useful when content comes from outside the system: imported logos, user uploads, third-party brand kits.

A stray hue · ranked against the palette · closest wins.
brand.set('logo', closestColor(
  color('#7f4dc4'),
  palette,
))

furthestFrom

furthestFrom( scope, { readableOn?, minContrast?, not? } )

The odd one out. The color that stands apart from the rest.

The member with the greatest average OKLab distance to every other member — the swatch nothing else can be confused with. Exclude members with not and the pick moves to the next outsider.

Click a swatch to exclude it · the odd one out re-picks.
ui.set('highlight', furthestFrom(brand, {
  not: [ref('brand.ink')],
}))

minContrastWith

minContrastWith( target, scope, { ratio?, not? } )

As subtle as you can get away with. And still legible.

The lowest contrast that still clears a ratio (4.5 by default). Hairlines, captions, dim labels — the quiet stuff that survives the next contrast audit because the audit is built in.

Same surface, same palette — three ratios, three answers.
ratio 3 — AA large
ratio 4.5 — AA
ratio 7 — AAA
ui.set('caption', minContrastWith(
  ref('ui.surface'),
  neutral,
  { ratio: 4.5 },
))

nextLarger · nextSmaller

nextLarger|nextSmaller( target, scope, { minDistance?, not? } )

Spacing that knows its own scale.

Step to the next member up or down — in px, rem, ms, whatever you've defined. Add a step to the scale and every gap, padding, and duration that referenced it moves with you.

Drag the target. Min-distance filters out steps that are too close.
nextSmaller —
target 12px
nextLarger —
ui.set('gap', nextLarger(
  ref('space.m'),
  space,
  { minDistance: 6 },
))

mostVivid · leastVivid

mostVivid|leastVivid( scope, { readableOn?, minContrast?, not? } )

Your accent is already in your palette.

Both ends of the chroma axis. mostVivid finds the loudest member — the obvious accent. leastVivid finds the neutral hiding inside your brand colors. Either can be limited to colors that read on a given surface, so the answer can't fall out of compliance.

Same palette, sorted by chroma — both ends marked.
ui.set('accent',  mostVivid(brand))
ui.set('surface', leastVivid(brand))

lightest · darkest

lightest|darkest( scope, { readableOn?, minContrast?, not? } )

The ends of the scale, wherever they moved to.

The members with the highest and lowest OKLCH lightness. Name your surface after lightest and your ink after darkest, and a re-generated palette can't strand them. Add readableOn and the pick is limited to members that still 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? } )

Randomness, with a memory.

Pick from a scope at random — but each declaration captures its own seed, so the value stays put across re-resolves and machines. Reshuffle deliberately per release, per user, per environment. Type filters the pool: color, dimension, or string.

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

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

nth

nth( scope, index, { not? } )

Pick by position, not by name.

When a ramp or scale generates your palette, the order is the meaning — lightest first, darkest last. nth addresses that order directly: integers work like Array.at() (-1 is the last member), and fractions between 0 and 1 select relatively — so 0.5 is always the midtone, however many stops were generated. (1.0 is the integer 1; reach the end with -1.)

// ramp generated lightest → darkest
ui.set('surface', nth(ramp, 0))    // lightest
ui.set('text',    nth(ramp, -1))   // darkest
ui.set('muted',   nth(ramp, 0.5)) // midtone
ui.set('subtle',  nth(ramp, 0.1)) // off-white

sibling

sibling( ref, offset, { wrap?, not? } )

One step lighter than whatever the surface is.

Steps through the scope a token lives in, relative to that token: +1 is the next member, -1 the previous one. Hover and pressed states follow the surface instead of hard-coding a second shade. Steps stop at the ends, or go round with wrap.

Click a tile to move the anchor · pick an offset.
ui.set('surface',       ref('gray.g100'))
ui.set('surface-hover', sibling(ref('gray.g100'), 1))
ui.set('surface-press', sibling(ref('gray.g100'), 2))

Readable is a filter, not a hope.

Every color selector apart from the two contrast ones takes readableOn and minContrast (4.5 by default). Members that don't reach the ratio against that backdrop leave the pool before ranking — so mostVivid returns the loudest color that still reads, closestColor the nearest readable one. If nothing passes, the token fails loudly instead of shipping an unreadable color. The backdrop is a reference, so a theme switch re-runs the filter.

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

One color in. A full scale out.

Pick a brand color. ramp() returns the matching stop from a Tailwind-style tonal scale — perceptually tuned via dittotones, which transfers the lightness and chroma curves of established palettes onto any hue. Eleven stops, one call each, all reactive.

brand.set('primary', color('#0066cc'));

const palette = book.addScope('palette');
for (const shade of ['50','100',...,'950']) {
  palette.set(shade,
    ramp(ref('brand.primary'), { shade }));
}
brand.primary

Name it, too.

Generated primitives still need keys. scaleNames() picks 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 where they belong. Changed the count? Run it again: every step gets a fresh, evenly spread name.


      

Try every scheme in the naming playground →

brand.primary

Don't arrange the palette. Let it sort itself.

Give a scope an order and it stays sorted — by value, name or type, composed as a hierarchy. The value orderer routes colors through colorsort-js for a perceptually smooth sequence, and dimensions by magnitude. The order is canonical — every renderer and every positional selector (nth, nextLarger) reads it — and it re-sorts the moment a token's value changes. Children inherit a parent's order unless they set their own.

const palette = book.addScope('palette', {
  order: [{ by: 'value' }],  // perceptual
});

// insert in any order — it stays sorted
const p = ref('brand.primary');
palette.set('teal',
  relativeTo(p, 'oklch', [.7, .14, '+190']));
palette.set('coral',
  relativeTo(p, 'oklch', [.7, .14, '+20']));

palette.getAllKeys();  // one smooth order
brand.primary
Eight colors, inserted scrambled. Pick a rule — the scope re-sorts itself. Change the seed — the whole order recolors.

Inherit. Override what changes.

Across a whole system. Every shop in a chain is the same brand, but the old-town store has its own accent. Write the store system once as a layer; each store is a layer that holds nothing but what makes it different — here, one color. Compose a book from the stack and the override lands in the same graph as everything else, so every ref, mix and selector derived from that root recomputes.

const system = layer('store-system', (book) => {
  const brand = book.addScope('brand');
  brand.set('highlight', color('#2d60a5'));
  // … 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('#d9480f') },
});
const harbour = layer('harbour', {
  brand: { highlight: color('#0c8599') },
});

composeBook('old-town', [system, oldTown]);
old-town on store-system
brand.highlight color('#d9480f') old-town
ui.link ref → #d9480f
ui.hover darken → #a13308
ui.tint colorMix → #fdddcb
brand.ink store-system → #1d1c1c
brand.paper store-system → #fcf6ee
One override. Everything derived from it follows; the rest stays dim.

Inside one book. When both variants live side by side, a scope can extend another. Every token cascades through unless you redefine it locally — and the dependency graph still knows: change a parent token and every descendant that inherits it recomputes. The editor dims inherited rows, so only the changes are bright. Inherited references keep pointing at the keys they name, so reach for layers once a theme has to re-route what is derived from a root.

const light = book.addScope('light');
light.set('bg',      color('#f5f5f5'));
light.set('text',    color('#1a1a1a'));
light.set('primary', color('#0066cc'));
light.set('accent',  color('#ff8800'));
light.set('muted',   color('#888888'));

// dark inherits all five, overrides two
const dark = book.addScope('dark', {
  extends: 'light',
});
dark.set('bg',   color('#1a1a1a'));
dark.set('text', color('#f5f5f5'));
dark extends light
bg color('#1a1a1a')
text color('#f5f5f5')
primary inherit → #0066cc
accent inherit → #ff8800
muted inherit → #888888
Inherited rows dimmed. The overrides are the only thing you read.

Type styles travel whole.

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

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.
One scope. Two tokens. The variant reads the rest from its base.

Value transforms.

Eight one-shot operations that take an input and apply a formula. Useful for the small decisions — a hover shade, a scale step, a timing curve — without leaving the graph.

Two colors. Pick the path.

colorMix() interpolates between two colors. The space you pick changes the path: rgb goes straight through whatever sits between the endpoints in cube coordinates; lab and oklab follow perceptual lines; lch, oklch and hsl rotate around hue. Same endpoints — wildly different middles. Any color-mix() space works, and the CSS renderer emits the matching color-mix().

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

One source, many outputs.

The same book renders to whatever target you need. CSS variables and JSON ship in the box; the W3 Design Tokens format too. Custom renderers register through one call — book.registerRenderer(name, fn) — and twenty lines of TypeScript turn the graph into Tailwind config, Swift extensions, SwiftUI, Kotlin, a JSON dialect of your choice.


    

    

    

  
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's now a peer of the built-ins.
book.registerRenderer('tailwind', tailwindRenderer);
const config = book.render('tailwind');

Bring your own rules. Bring your own libraries.

The built-in selectors are a starting set. Register any function that takes the token's resolved arguments (scopes arrive as scopes) plus its options and returns a value. Tokens that call it take part in the graph like the built-ins: dependency tracking, cycle checks, reactive recomputation.

A custom function

Register a new selector — warmest — that picks the member leaning hardest towards orange: chroma times how close its hue sits 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]));

ui.warm = warmest( values ) —

Integration with a library

Poline generates a perceptual ramp from two anchor colors. Pipe it straight into a scope; selectors work on it just like any other palette.

// anchors as [hue, saturation, lightness], s and l in 0–1
const poline = new Poline({
  anchorColors: [[20, .3, .08], [12, .85, .45], [42, .4, .92]],
  numPoints: 7,
});
const ramp = book.addScope('ramp');
poline.colorsCSS.forEach((c, i) => ramp.set(`s${i}`, color(c)));

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

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

Express the rules. Let the system resolve them.

$ npm i design-book