Follow

Design Token Graph

The design system's tokens, as an explorable graph instead of a flat list

Design tokens are a graph long before they are a list — semantic tokens reference core tokens, component tokens reference semantic ones, and every theme overrides a subset.

This package builds that graph out of the dictionary and renders it with sigma.js/graphology, so you can see which tokens drive what. It is wired into the design-system build and ships as a workspace package in the Storyblok starter.


FactDetail
Part ofruhmesmeile Storyblok starter (`packages/token-graph`)
StackTypeScript, sigma.js, graphology
Output`token-graph.json` in the design-system dist
Started2026

What's in it

References, not columns

Nodes are tokens; edges are the var() references between them, grouped by layer.

Built from the real tokens

Extraction runs as a design-system build step, so the graph cannot drift from the source.

In detail

What is actually inside it

Louvain, not an arbitrary palette

Grouping is computed from the graph's own structure, so the colouring surfaces how tokens clump (core → semantic → component) instead of imposing a taxonomy someone has to maintain.

Edges are references

Every edge is a real var() reference, extracted during the design-system build — which means the graph cannot be right while the tokens are wrong.

Traversal and components

graphology-traversal and graphology-components are in the dependency list: the app can walk from a token to its dependencies and spot orphaned subtrees.

DetailValue
Package@kickstartds/token-graph
Stacksigma.js, graphology, louvain, forceatlas2, Radix UI
Inputgenerated during the design-system build

Technical notes

The decisions behind it

Why sigma.js and not a CSS diagram?
Token graphs in a mature system are hundreds of nodes with cross-references. WebGL plus a force layout is what makes that explorable in a browser tab instead of a poster.
How does it stay accurate?
Extraction runs as a design-system build step and lands in dist/tokens/token-graph.json, so the graph is regenerated with the tokens rather than curated by hand.

How token-graph.json gets built

Three CSS sources, one merged property map, two serialized views

token-graph.json is not parsed at runtime. packages/token-graph/scripts/extractTokenGraph.mjs runs as the token-graph step of the design-system build script (node ../token-graph/scripts/extractTokenGraph.mjs . src/token/token-graph.json) and writes the artifact that is later copied to dist/tokens/token-graph.json.

It reads three inputs and merges them: every dist/components/**/*.css except page-wrapper, dist/global.css (semantic + base + normalize), and the Style Dictionary output src/token/tokens.css. Each is run through custom-property-extract with mode: "full", then the maps are deep-merged — arrays are concatenated with a reference-equality de-duplication that, because every extraction produces fresh objects, in practice keeps both copies of a property.

Every declaration becomes a node keyed by the trimmed custom-property name. Every var() inside a value becomes a directed edge from the referencing property to the referenced one — the CSS lookup direction, not the declaration order — carrying purpose: "reference" and the selector it was found under. Edge pairs are de-duplicated on ${source}\0${target}, so a token referenced from twenty selectors still contributes exactly one edge, with the first selector it was seen with.

There are two views in one file. design-system keeps nodes prefixed --c-, --l- or --dsa-, pulls in everything those reference, then keeps only the largest connected component — a stray token island cannot drag disconnected nodes into the default view. Membership and colour use different prefix lists: --c- belongs to the design-system view but still gets the fallback #00F218, because only --ks- (#ecff00) and --dsa- (#e21879) are mapped to colours.

3057
nodes in the full graph
1985 --ks-, 831 --dsa-, 136 --c-, 31 --l-; 46 of them exist only because something references them, so they carry no selector at all.
5149
edges in the full graph
Every one is a var() reference. The busiest target is --ks-color-fg with 103 incoming edges; the --ks-brand-color-scale-* tokens take 44 each. No token references more than 10 others.
1189
nodes in the design-system view
1706 edges. 486 of the nodes are --ks- tokens that got pulled in because a --dsa- component token references them — the subgraph follows references out of the prefix set, not just the prefix list.
2.
MB for the committed JSON
2,756,694 bytes written with spaces: 0. The ADR predicted the serialized graph would come out smaller than the original 424 KB CSS blob; per-property selector lists pushed it about 6.5× above that estimate.

Inside the viewer

What runs in the browser after the JSON lands

Everything structural is computed client-side. The JSON ships nodes with random x/y seeds and no community attribute at all; louvain.assign(graph, { nodeCommunityAttribute: "community" }) runs on the imported graphology instance, and community names are generated from the graph itself — the three most frequent words across a community's token names, after --, __, -, _ and short digit runs are stripped, joined with /. Anything with 25 tokens or fewer is filtered out of the communities menu before it is ever rendered.

The visual system is deliberately reproducible. Palettes come from iwanthue in the intense colour space with a fixed cool-palette seed and quality: 100, so a community keeps its colour across reloads. Filtering dims rather than deletes: non-matching nodes drop to 20 % #CCCCCC blended against the rgb(2, 53, 66) canvas — or disappear entirely when automatic relayout is on. Component and community selections add WebGL density contours on top instead of moving the graph.

Two limitations are built in on purpose. The graph is a build artifact, so it is only as fresh as the last design-system build — live re-rendering on edit is explicitly deferred, and clicking a node does not yet jump into the token editor. And the whole view is React.lazy()-split: sigma, graphology and the WebGL programs only download when the Graph tab is opened.

ForceAtlas2 that skips what you filtered

The layout is @react-sigma/layout-forceatlas2 with iterations: 100 and an outputReducer that returns null for hidden nodes — tokens removed by a filter do not drag the remaining layout around. Nodes start from random x/y in [0, 1]; retrigger layout re-runs assign() once the arrangement has drifted.

Contours are a WebGL layer, not SVG

Selecting a component or community calls bindWebGLLayer + createContoursProgram from @sigma/layer-webgl: a density heat overlay with radius: 100, a 1 px border, and seven levels at thresholds 0.3, 2, 4, 8, 10, 15, 21 fading from alpha 0.5 to 0.1. Colours are blended against the background with color-blend, and toggling the item again runs the returned cleanup function to drop the layer.

Curved only where it must be

Straight edges use sigma's EdgeArrowProgram; parallel edges between the same pair are re-typed curved with @sigma/edge-curve and get a curvature of DEFAULT_EDGE_CURVATURE + 3 × DEFAULT_EDGE_CURVATURE × parallelIndex / parallelMaxIndex, so bundles fan out instead of stacking.

Traversal is a real BFS, with depth

graphology-traversal's bfsFromNode walks outbound (descendents) or inbound (ascendents) from the selected token. The +/− level buttons recurse: below the configured depth, every discovered node expands one hop in the opposite direction, so a component token and the tokens it depends on stay in view together. Selecting also animates the camera to the node at ratio: 0.1 — but only if you had not already panned or zoomed.

Full vs. design-system is a view swap

The toolbar toggle does not filter the graph; it switches between the two serialized views (defaultGraph is design-system, and graphNames drives the toggle). The adjacent show unconnected tokens control is what flips to full, which is why it produces a different set of nodes rather than simply revealing dimmed ones.

Cmd/Ctrl+K lists every node

A cmdk command palette builds one Command.Item per node in the active graph, so search is client-side over the full token list. Clicking a node in the canvas runs the same selection path as picking it from the palette; the palette's Reset item clears the selection and restores the default camera.