A reactive TypeScript design system framework. Define tokens with relationships, compute derived values automatically, and render to CSS, JSON, or W3 Design Tokens.
Design systems are usually built as collections of fixed values — a color picked here, a spacing value decided there, each choice made in isolation and checked against every other. Design Book takes a different approach: instead of encoding results, you encode relationships.
A text color isn't #ffffff — it's "the highest-contrast color from this palette against this background." A hover state isn't a manually darkened hex — it's "primary mixed 15% toward black." Change the primary color once, and every relationship updates: contrast pairs recalculate, derived tones shift, spacing scales recompute. The system maintains coherence across complexity you couldn't track by hand.
This idea — that relationships matter more than individual choices — means your design decisions become transparent, auditable, and reactive. You can see why a color was chosen, not just what it is. And when the inputs change, the logic holds.
npm install design-bookimport {
DesignBook, color, ref, px, rem,
bestContrastWith, colorMix, relativeTo,
Renderer, SVGRenderer,
} from 'design-book';
const book = new DesignBook('my-system');
// Define base tokens
const brand = book.addScope('brand');
brand.set('primary', color('#0066cc'));
brand.set('neutral', color('#1a1a1a'));
brand.set('white', color('#ffffff'));
brand.set('space', px(16));
// Derived tokens with references and functions
const ui = book.addScope('ui');
ui.set('background', ref('brand.white'));
ui.set('text', bestContrastWith(ref('ui.background'), brand));
ui.set('hover', colorMix(ref('brand.primary'), color('#000000'), { ratio: 0.15 }));
ui.set('complement', relativeTo(ref('brand.primary'), 'oklch', [null, null, '+180']));
// Reactive — change a base token, dependents update automatically
const stopWatching = book.watch('ui.text', (newValue, detail) => {
console.log('Text color changed to', newValue);
console.log('Changed key:', detail.key);
});
brand.set('white', color('#f5f5f5')); // triggers re-computation
stopWatching();
// Render
const css = new Renderer(book, 'css-variables').render();
const json = new Renderer(book, 'json').render();
const jsonObject = new Renderer(book, 'json').renderJsonObject();
const w3 = new Renderer(book, 'w3-design-tokens').render();
const w3Object = new Renderer(book, 'w3-design-tokens').renderW3DesignTokensObject();
const svg = new SVGRenderer(book).render();All constructors validate their input and throw on invalid values.
color('#0066cc') // Any CSS color — hex, named, rgb(), hsl()
color('rebeccapurple') // Named colors work too
ref('scope.token') // Reference to another token
px(16) // Dimension shortcuts
rem(1.5)
ms(200)
dimension(100, 'vh') // Generic — any unit
string('Arial, sans-serif') // String valuesbestContrastWith(target, scope) // Highest WCAG contrast
minContrastWith(target, scope, { ratio }) // Meets minimum ratio (default 4.5)
closestColor(target, scope) // Perceptually closest
furthestFrom(scope) // Most distant from others
averageColor(scope, { colorSpace }) // Average of all colors
mostVivid(scope, { against, minContrast }) // Highest OKLCH chroma, optionally gated by readabilitymostVivid uses OKLCH chroma rather than HSL saturation so a pale pink and a vivid mid-red don't score the same. Pass against (a target colour) and minContrast to require the result to clear a WCAG threshold against that target — useful for picking an accent / link colour out of a generated palette without it turning unreadable. Falls back to the highest-contrast candidate if nothing meets the threshold, same as minContrastWith.
Every scope-iterating function above accepts a not option — an array of
fully-qualified token keys (or ref(...) calls) that should be skipped during
the search. Useful when a value carries a role you don't want to reuse
elsewhere — values.error shouldn't be the accent colour even if it happens
to have the highest chroma.
ui.set('accent', mostVivid(palette, {
against: ref('ui.surface'),
minContrast: 4.5,
not: [ref('palette.error'), ref('palette.success')],
}));
// `not` is also available on bestContrastWith, minContrastWith,
// closestColor, furthestFrom and averageColor.Plain strings work too — not: ['palette.error'] is equivalent to
not: [ref('palette.error')].
colorMix(color1, color2, { ratio, colorSpace }) // Interpolate two colors
lighten(color, { amount }) // Increase lightness
darken(color, { amount }) // Decrease lightness
shade(color, { amount }) // Tonal step that adapts: darkens if input is light, lightens if dark
relativeTo(color, 'oklch', [null, null, '+180']) // Per-channel modificationshade is useful when you want a subtle variation that's always visible against the input — darken(surface) collapses to black when the surface is already dark, but shade(surface) flips direction and lightens instead. Picks based on OKLCH lightness: > 0.5 darkens, ≤ 0.5 lightens.
Channel modifications for relativeTo: null (keep), number (set), "+N" "-N" "*N" "/N" (relative).
spacingScale(base, { multiplier }) // Multiply dimension
typographyScale(base, { ratio, step }) // Modular scale
timing(duration, 'ease-out', { delay }) // Timing stringYou can register your own functions and use them as procedural tokens the same way the built-ins work. Two pieces:
- An implementation — a plain function that receives the already
resolved arguments (strings for refs, scope objects for
ScopeFunctionArginputs), plus an optionaloptionsobject as the last argument, and returns a string. - A constructor that wraps it as a
FunctionTokenValue— callcreateFunctionToken('name', args, { options, metadata }). Themetadata.dependenciesarray tells the graph which refs the token reads from so changes propagate;metadata.visualDependencieslists scope keys it iterates (for analysis functions likebestContrastWith).
import {
DesignBook, color, ref, px,
createFunctionToken, extractDependencies,
} from 'design-book';
import type { TokenValue, ReferenceValue, FunctionTokenValue } from 'design-book';
// 1. Implementation. Receives the resolved colour as a string and the options.
function multiplyAlphaImpl(colorValue: string, alpha: number): string {
// (Use any parser you like — culori, chroma-js, your own. Returns CSS.)
return colorValue.replace(/#([0-9a-f]{6})$/i, (_, hex) => {
const hexA = Math.round(alpha * 255).toString(16).padStart(2, '0');
return `#${hex}${hexA}`;
});
}
// 2. Constructor. Wraps the impl as a function token.
function multiplyAlpha(
baseColor: TokenValue | ReferenceValue | FunctionTokenValue,
options?: { alpha?: number },
): FunctionTokenValue {
return createFunctionToken('multiplyAlpha', [baseColor], {
options: { alpha: options?.alpha ?? 1 },
metadata: {
dependencies: extractDependencies([baseColor]),
visualDependencies: [],
returnType: 'color',
},
});
}
// 3. Register the impl on every book that should know about it.
const book = new DesignBook('with-custom-fns');
book.registerFunction(
'multiplyAlpha',
(colorValue: string, options?: { alpha?: number }) =>
multiplyAlphaImpl(colorValue, options?.alpha ?? 1),
);
// 4. Use it just like a built-in. Custom functions can nest inside other
// function tokens (built-in or custom) too.
const brand = book.addScope('brand');
brand.set('primary', color('#0066cc'));
const ui = book.addScope('ui');
ui.set('overlay', multiplyAlpha(ref('brand.primary'), { alpha: 0.5 }));
book.resolve('ui.overlay'); // '#0066cc80'The same pattern handles scope-iterating analysers — pass the scope as an
arg and populate metadata.visualDependencies via
extractVisualDependencies([scope]) so the dependency graph knows which
keys the function reads from.
If you want your custom function to render as native CSS (e.g. as a
color-mix expression instead of the resolved hex), register a function
renderer on the Renderer:
import { Renderer } from 'design-book';
const renderer = new Renderer(book, 'css-variables');
renderer.registerFunctionRenderer('multiplyAlpha', (args, options) => {
// `args` are the unresolved FunctionArg values; emit any CSS expression.
return `rgb(from ${argToCss(args[0])} r g b / ${(options?.alpha as number) ?? 1})`;
});Without a renderer, the CSS output falls back to the resolved string.
const light = book.addScope('light');
light.set('bg', color('#ffffff'));
light.set('text', color('#1a1a1a'));
// Dark theme inherits from light, overrides specific tokens
const dark = book.addScope('dark', { extends: 'light' });
dark.set('bg', color('#1a1a1a'));
dark.set('text', color('#ffffff'));
// dark still inherits any tokens from light that aren't overridden
// If you later delete a local override, the scope falls back to the inherited token again
dark.delete('text');
dark.resolve('text'); // '#1a1a1a'Inherited tokens remain part of the dependency graph. If dark.primary currently resolves from light.primary, anything depending on dark.primary will continue to update when light.primary changes.
:root {
--brand-primary: #0066cc;
--ui-background: var(--brand-white);
--ui-hover: color-mix(in lab, var(--brand-primary) 85%, #000000);
--ui-complement: color(from var(--brand-primary) oklch l c calc(h + 180));
}References become var(), functions become CSS-native where possible (color-mix, calc, color(from ...)).
{
"brand.primary": "#0066cc",
"ui.background": "#ffffff",
"ui.hover": "#0057ad"
}All values fully resolved.
If you want structured data instead of a JSON string, use renderJsonObject().
{
"brand": {
"primary": {
"$value": { "colorSpace": "srgb", "components": [0, 0.4, 0.8], "alpha": 1, "hex": "#0066cc" },
"$type": "color",
"$description": "Main brand color"
},
"space": {
"$value": { "value": 16, "unit": "px" },
"$type": "dimension"
}
}
}Follows the W3 Design Tokens spec: structured color/dimension/duration values, $description support, references as {scope.token}.
If you want the structured token object directly, use renderW3DesignTokensObject().
For documentation pages or admin UIs, TableViewRenderer outputs an HTML
<table> with one row per token — qualified key, type, resolved value
(with an optional inline colour swatch), and the dependency list.
import { TableViewRenderer } from 'design-book';
const html = new TableViewRenderer(book).render();
// <table class="design-book-table">…</table>Options: className (root element class), inlineColorSwatches
(default true), showInheritance (default true — annotates inherited
rows with the source key).
const dispose = book.on('tokenChanged', (e) => { /* e.detail.key, e.detail.newValue */ });
book.on('change', (e) => { /* e.detail.changedKeys, e.detail.scopes */ });
book.on('scopeAdded', (e) => { /* e.detail.scope */ });
book.on('scopeRemoved', (e) => { /* e.detail.scope, e.detail.removedKeys */ });
book.on('batch-failed', (e) => { /* e.detail.processed, e.detail.errors */ });
book.on('batch-complete', (e) => { /* e.detail.processed */ });
book.watch('brand.primary', (newValue, detail) => {
// newValue is undefined when the token no longer resolves
// detail contains the underlying tokenChanged event payload
});
dispose();book.on() and book.watch() both return unsubscribe functions.
book.getSourceKey('dark.primary'); // 'light.primary' when inherited
book.isInherited('dark.primary'); // true when the active value comes from a parent scopeThis is useful when you want to distinguish local overrides from inherited values without inspecting scope internals.
Bundles everything you usually want about a token into one call — the
resolved value, the underlying token shape (value / ref / function), the
graph dependencies + dependents, and any inheritance source. Replaces the
three-call pattern of resolve + getTokenByKey + graph.getIncoming.
book.inspect('ui.hover');
// {
// key: 'ui.hover',
// value: '#0057ad',
// tokenType: 'function',
// function: 'darken',
// args: [<refToken>],
// options: { amount: 0.15 },
// returnType: 'color',
// dependencies: ['brand.primary'],
// dependents: ['card.border'],
// isInherited: false,
// }Returns null if the key isn't registered. Reference and value tokens
populate the corresponding extra fields (refKey for refs; rawValue
and unit for value tokens).
book.mode = 'batch';
brand.set('primary', color('#ff0000'));
brand.set('secondary', color('#00ff00'));
const result = book.flush(); // { processed: [...], errors: [...] }
book.mode = 'auto';const graph = book.getDependencyGraph();
graph.getDependentsOf('brand.primary'); // What depends on this token
graph.getPrerequisitesFor('ui.text'); // What this token depends on
graph.getEvaluationOrderFor('ui.text'); // Resolution order
graph.findShortestPath('brand.primary', 'ui.text');
graph.hasCycles();
graph.getAdjacencyList(); // { 'brand.primary': ['ui.text', 'ui.hover'], … }
graph.getAdjacencyList(true); // upstream: incoming edges per nodeFor inherited tokens, prerequisites reflect the active source token. If dark.primary is inherited from light.primary, graph.getPrerequisitesFor('dark.primary') includes light.primary.
Run npm run dev to start the interactive editor. Features:
- CodeMirror 6 with context-aware autocomplete
- Inline color swatches
- Live CSS / JSON / W3 output
- SVG dependency visualization
- Error highlighting for invalid values
One useful way to work with Design Book is to separate your system into three layers:
- Generate raw color primitives with a palette tool such as Poline or RampenSau
- Define semantic tokens as relationships over those primitives
- Feed UI tokens and components from the semantic layer instead of hard-coded colors
That keeps your palette exploratory while your product tokens stay stable and meaningful.
For example, Poline can generate a palette from a small set of anchor colors:
import { Poline } from 'poline';
const poline = new Poline({
anchorColors: [
[230, 0.65, 0.2],
[210, 0.9, 0.55],
[160, 0.7, 0.78],
],
numPoints: 4,
});
const palette = poline.colorsCSS;If you prefer a ramp-oriented workflow, RampenSau is a good fit for generating a light-to-dark sequence first and then mapping roles onto it.
import {
DesignBook, color, ref,
bestContrastWith, closestColor, colorMix,
} from 'design-book';
const book = new DesignBook('workflow');
const primitive = book.addScope('primitive');
primitive.set('blue-900', color('#102a43'));
primitive.set('blue-700', color('#1f5f8b'));
primitive.set('blue-500', color('#2f80ed'));
primitive.set('mint-300', color('#7ad9b6'));
primitive.set('sand-100', color('#f6efe7'));
primitive.set('ink-900', color('#111111'));
primitive.set('white', color('#ffffff'));In a real pipeline, those primitive values would usually be imported from Poline, RampenSau, or another color-generation step rather than typed by hand.
const semantic = book.addScope('semantic');
semantic.set('surface', ref('primitive.sand-100'));
semantic.set('surface-accent', ref('primitive.blue-500'));
semantic.set('surface-accent-hover', colorMix(
ref('semantic.surface-accent'),
ref('primitive.ink-900'),
{ ratio: 0.12 },
));
semantic.set('text', bestContrastWith(ref('semantic.surface'), primitive));
semantic.set('text-on-accent', bestContrastWith(ref('semantic.surface-accent'), primitive));
semantic.set('border-subtle', closestColor(ref('semantic.surface'), primitive));
semantic.set('focus-ring', ref('primitive.mint-300'));This is where Design Book becomes useful: instead of deciding every UI color manually, you encode the rule.
textis whichever primitive gives the best contrast on the current surfacetext-on-accentstays legible even if the accent color changessurface-accent-hoveris derived from the accent token, not maintained separately
const button = book.addScope('button');
button.set('background', ref('semantic.surface-accent'));
button.set('background-hover', ref('semantic.surface-accent-hover'));
button.set('text', ref('semantic.text-on-accent'));
button.set('border', ref('semantic.border-subtle'));
button.set('focus-ring', ref('semantic.focus-ring'));Now your components depend on meaning, not on palette coordinates or literal hex values.
If you regenerate the primitive palette, the semantic and component layers recompute automatically as long as the token relationships still make sense.
- Palette tools stay free to explore hue, ramp shape, and tonal structure
- Semantic tokens preserve intent such as
surface,text,accent, andfocus-ring - UI scopes stay stable even when the underlying palette changes
- Accessibility rules can live in the token graph instead of in design review folklore
Design Book is strongest in that middle layer: not generating colors, but turning a generated palette into a maintainable, explainable system.
The package ships with a Claude Code skill file at skills/design-book.md. It teaches Claude how to migrate or retrofit a static design system onto Design Book — discovering tokens, classifying them into the value / reference / procedural layers, generating the equivalent Design Book code, and verifying the result.
It includes a Figma-specific path (via the Figma Dev Mode MCP server when available, the Figma REST API otherwise) that maps Figma variables, collections and modes to Design Book scopes, refs and extends-inheritance.
To use it, copy the file into a Claude Code project:
mkdir -p .claude/skills
cp node_modules/design-book/skills/design-book.md .claude/skills/Then prompts like "migrate this Tailwind config to design-book", "import these Figma variables", or "retrofit our CSS variables onto design-book" will trigger the workflow.
You can also read it as a plain migration guide — it doesn't require Claude Code to be useful.
AGPL-3.0 — free for open-source projects. If you want to use Design Book in proprietary or closed-source software without open-sourcing your project, a commercial license is available. Contact david@elastq.ch.