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. Every relationship in a Design Book — references, computed values, scope-iterating selectors — is a tracked edge in a graph. Change one input and the library knows exactly what to recompute, refusing cycles at write time.

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

Ten built-in selectors. Each one takes a scope of candidates and returns the member that fits a rule.

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'),
  scope('brand'),
))

closestColor

closestColor( target, scope, { 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'),
  scope('palette'),
))

furthestFrom

furthestFrom( scope, { not? } )

The accent that can't be confused with the brand.

Maximum perceptual distance from an anchor. Pick the swatch designed for "look here, not there" — and let it re-pick itself when the anchor moves.

Hover any swatch · the furthest member is marked.
ui.set('accent', furthestFrom(
  scope('brand'),
))

minContrastWith

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

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

The lowest contrast that still clears a threshold. Hairlines, captions, dim labels — the quiet stuff that survives the next contrast audit because the audit is built in.

Same surface, same palette — three thresholds, three answers.
threshold 3.0
threshold 4.5 — AA
threshold 7.0 — AAA
ui.set('caption', minContrastWith(
  ref('ui.surface'),
  scope('neutral'),
  { threshold: 4.5 },
))

nextLarger · nextSmaller

nextLarger( target, scope, { minDistance? } )

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'),
  scope('space'),
  { minDistance: 6 },
))

mostVivid · leastVivid

mostVivid|leastVivid( scope, { against?, 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 constrained to clear a contrast target, so the answer can't fall out of compliance.

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

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(scope('brand'), { type: 'color' }))

ui.set('promo-bg',
  random(scope('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() (negative wraps from the end), and floats between 0 and 1 select relatively — so 0.5 is always the midtone, regardless of how many stops were generated.

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

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

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.

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

A style is a scope.

A type style is a collection of properties — family, size, weight, line-height, tracking. Design Book treats it as a scope with a compose: 'typography' marker, so every property stays a real, ref-able token in the graph. Renderers re-aggregate the scope at output time — a CSS class for the browser, a $type: 'typography' composite for the W3 Design Tokens spec.

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

// Variant via extends — inherits the compose marker.
book.addTypography('hero-title',
  { fontSize: rem(4) },
  { extends: 'heading-lg' });
A style is a scope.
Variants extend.
Same book. Two scopes. One inherits the other.

Inherit. Override what changes.

A scope can extend another. Every token cascades through unless you redefine it locally — and the dependency graph still knows. Change a parent token, every dependent in every descendant recomputes. The editor dims inherited rows so the decision is visible at a glance — only the changes are bright.

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 everything, then overrides two tokens.
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.

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. Anything that fits the shape (scope, options) → string can be registered as a function token — full participation in the graph, dependency tracking, reactive recomputation.

A custom function

Register a new selector — darkestReadable — that picks the darkest scope member still clearing AA against a surface. Twelve lines.

function darkestReadableImpl(against, scope, ratio = 4.5) {
  let best = null, bestL = 2;
  for (const key of scope.getAllKeys()) {
    const hex = scope.resolve(key);
    if (wcagContrast(against, hex) < ratio) continue;
    const L = oklch(hex).l;
    if (L < bestL) { bestL = L; best = hex; }
  }
  return best;
}
book.registerFunction('darkestReadable', darkestReadableImpl);

darkestReadable( ui.surface, 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.

const poline = new Poline({
  anchorColors: [[14,17,14], [200,57,26]],
  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,
));

minContrastWith( ui.surface, ramp ) →

Reactive by default.

The graph is live. Mutate a token in a scope and every dependent re-resolves on the next tick — or all at once if you batch. Subscribe to a key with watch(key, fn) and the library will call you back with the new value and the chain of changes that produced it. Cycles are caught at write time, not at render.

Value transforms.

Ten 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.

Express the rules. Let the system resolve them.

$ npm i design-book