Introduction: Debt Accumulates Silently
Every design system ships with good intentions and a clean token file. Then the product grows. A brand refresh renames half the palette. A modal variant is replaced and never removed. A hard-coded hex slips into a hotfix and stays for two years. Nothing breaks, so nothing gets cleaned.
Design-system maintenance debt becomes manageable the moment it becomes computable: three checks — dead tokens, orphaned components, and hard-coded values that shadow tokens — combine into a single weighted DS Debt Score you can trend in CI.
This guide defines those three checks with explicit pass-fail logic, then shows how to combine them into one trendable number. It is written for design engineers and design-system maintainers who already live in tokens, components, and pipelines, and who want audit rules they can implement rather than advice to clean up when you have time. It does not quantify team time in any unit, and it does not promise savings. It gives you computable definitions you can automate.
How This Guide Was Built
This is desk research only, synthesized from official documentation. There was no hands-on testing of any tool, no test repository, and no evaluation environment.
There are no benchmarks, no run logs, no screenshots, and no before-and-after metrics in this guide. Tool claims link to the official docs that support them, including the DTCG Format Module 2025.10, Style Dictionary utilities, stylelint disallowed-list rule, stylelint plugin guide, knip issue types, knip reporters, and Figma boundVariables API. Where a behavior such as the Storybook entry-point setup is a convention rather than a documented default, the text says so.
The DS Debt Score described below is a proposed specification, not a validated metric. Weights are defaults for discussion, not empirical findings, and any code or JSON shown is labeled illustrative.
Check A: Dead Tokens With Zero Inbound Edges
A dead token is a token that exists but is no longer referenced by anything. The formal rule is:
dead(t) iff indeg(t) = 0, where indeg = aliasEdges + sourceEdges + figmaEdges.
In plain language: count every inbound edge to a token from three sources, and if the total is zero, the token is dead.
To apply this, you first need a consistent definition of what a token is. Under the DTCG Format Module 2025.10, a token has a required $value and an optional $type. A token with no $type inherits one from its closest parent group, and a token with no type anywhere is invalid. Aliases use {group.token} syntax. That structure tells your audit exactly what to parse: resolve every {group.token} alias, follow the chain, and do not count an unresolved reference as usage.
Build the reference graph with Style Dictionary utilities. Style Dictionary exposes usesReferences() and getReferences() for traversing the reference graph. Those helpers let you walk from one token to the aliases it references, which gives you aliasEdges. The traversal is custom: Style Dictionary has no built-in unused-token rule, so you must write the aggregation that collects in-degree counts across the whole token set.
The other two edge types close the loop between tokens, code, and design:
sourceEdges: references found in source files where tokens are consumed, such as themed CSS custom properties, Tailwind config mappings, or token imports. Static search findsvar(--color-action)ortokens.color.actiondepending on your delivery format.figmaEdges: references found in Figma where a variable is bound to a node. The Figma boundVariables API documents that aliasing is supported and that usage is derivable fromboundVariables. There is no native unused-variables report, so you must export or crawl bindings to count these edges.
The debt ratio for tokens is then:
D_token = dead tokens / total tokens
Critical caveat: dynamically built token names evade static matching. If your codebase constructs names like color-${variant} or looks them up from a CMS string, a static graph will report zero inbound edges even though the token is used at runtime. Iterate the in-degree count until no new zero-in-degree tokens appear, so a token referenced only by another dead token is also reported. Route suspects to a manual-review queue, never auto-delete. Deletion should require human confirmation that no dynamic path, theme override, or Figma-only usage exists.
Illustrative logic only, not a run log:
// illustrative snippet — conceptual aggregation, not tested code
// indeg = aliasEdges + sourceEdges + figmaEdges
if (aliasEdges + sourceEdges + figmaEdges === 0) {
queueForManualReview(token);
}
Check B: Orphaned Components via Export Analysis
A component can be perfectly built and completely unused. The formal rule is:
orphaned(c) iff c is reported under knip’s exports issue and c lives in the component directory (filter by path glob, e.g. src/components/**). D_component uses the same filter in its denominator.
knip’s issue keys include files, exports, and unresolved, among others (dependencies, types, duplicates). exports is the right signal for orphaned components. files is a useful secondary signal for abandoned component folders or styles that are no longer reachable.
Scope the entry points deliberately. knip treats exports from entry files as used by default, and a design-system package’s public barrel is one of those entries, so its components can never be flagged unless you decide that barrel is not an entry. Make that choice explicitly in your config and document it, because it decides whether entry-file exports are ever reported.
Storybook needs the opposite treatment from what most setups do by default. If your stories are registered as entry points, anything a story imports counts as used, so a component imported only by its own demo would look alive. Exclude *.stories.* from the project so story imports do not count as liveness. A component imported solely by its own story and by no app, docs, or test entry should be reported as orphaned.
Operationally, knip fits CI well. A JSON reporter exists for machine-readable output that your audit script can parse to compute debt ratios, and its non-zero exit code lets the pipeline fail or warn when debt crosses a threshold. Treat its output as input to your own score calculation.
The debt ratio for components is:
D_component = orphaned components / total components
Scope the denominator carefully. Decide whether it includes experimental, deprecated, and internal utility components, document that scope, and keep it stable so the trend is meaningful. Renaming a file without updating the barrel, splitting one component into two, or moving stories can temporarily inflate orphans. That is expected. The goal is a stable trend, not a zero on day one.
Check C: Token Shadowing by Hard-Coded Values
The third form of debt is subtler: the token exists and is used, but hard-coded literals duplicate its value elsewhere. The token system looks healthy while visual control leaks back into one-off values.
The rule for a single literal is:
duplicate(l) iff there exists a token t where normalize(l) = normalize(t.$value).
Normalize = lowercase, expand 3-digit hex, canonical spacing. In practice that means #FFF and #ffffff are the same after normalization, and RGB(0, 0, 0) and rgb(0,0,0) are the same after lowercasing and spacing canonicalization. Note that DTCG 2025.10 colors can be structured objects (a colorSpace plus components array), not strings, so convert them to a canonical hex string before comparison. Only compare literals of the same kind: colors to colors, spacing to spacing, and so on. Do not flag coincidental numeric matches across unrelated dimensions.
Detection starts with stylelint, which flags raw literals through rules such as declaration-property-value-disallowed-list. Stylelint reports that a declaration uses a literal where a token is expected. The duplicate-versus-token comparison is custom logic you add on top, typically as a stylelint plugin or a separate script.
The debt ratio is:
D_shadow = duplicates / total color-bearing declarations.
Start with color-bearing declarations because they are the highest-risk and easiest to normalize reliably. You can extend the same pattern to spacing, radii, or type later, but keep each dimension separate. A single global shadowing ratio that mixes colors and spacing hides more than it reveals.
This check also catches exact duplicates. When designers add #0A0A0A because they cannot find black-900 at #0a0a0a, normalization exposes the duplication. The fix is not always to replace the literal; sometimes the token set is missing a needed value. Either way, the computable signal forces an intentional decision. A true near-duplicate, such as #0b0b0b against #0a0a0a, needs a tolerance threshold and is a judgment call that this score does not include.
The DS Debt Score: One Trendable Number
Individual ratios are useful, but teams trend one number. The proposed composition is:
DS Debt Score = 0.40·D_token + 0.35·D_component + 0.25·D_shadow.
The weights sum to 1.0: 0.40 + 0.35 + 0.25 = 1.0, so the score stays on a 0 to 1 scale where 0 means no measured debt and 1 means total debt.
The weights are tunable defaults, not empirically validated. They encode a judgment call: tokens are weighted highest because dead tokens confuse every consumer, components next because orphans carry maintenance and bundle weight, and shadowing last because it is noisier and often requires design judgment. Your system may reasonably choose different weights. Document whatever you choose and keep it constant for at least a quarter so trends are comparable.
Arithmetic walkthrough, illustrative numbers only, not measured results:
Suppose a system has D_token = 0.20, D_component = 0.10, and D_shadow = 0.40. Then Score = 0.40 × 0.20 + 0.35 × 0.10 + 0.25 × 0.40 = 0.08 + 0.035 + 0.10 = 0.215. Track 0.215 over time, not as a grade but as a direction. A rising score after a rebrand is normal; a score that never falls indicates cleanup never ships.
Wire the three inputs into CI on a schedule rather than on every commit if your Figma crawl is slow. Fail softly at first: comment on pull requests when a change adds a dead token or a new shadowed literal, and fail the build only when the aggregate score regresses past a threshold you control. The precondition is computability. Once each ratio is deterministic from documented inputs, treating design-system debt like engineering debt becomes a workflow choice.
Tool Roles at a Glance
Use each tool only for what its documentation supports. None of these tools alone reports full system debt, but together they supply the documented primitives for the three checks.
| Tool | Role in the audit | Unused detection built in? | Key APIs / issue types | CI behavior |
|---|---|---|---|---|
| Style Dictionary | Resolve token aliases and traverse token reference graph | No — traversal is custom using documented helpers | usesReferences(), getReferences() | Scriptable audit step; no native unused-token gate |
| stylelint | Flag raw literals that may shadow tokens | No — requires custom plugin | declaration-property-value-disallowed-list, custom plugin API | Lint gate for literals; shadowing logic is custom |
| knip | Find orphaned components and files | Yes for code exports and files | files, exports, unresolved issue types | JSON reporter plus non-zero exit suits CI |
| Figma Variables | Provide design-side usage via bindings and aliases | No native unused-variables report; usage derivable from bindings | boundVariables, aliasing support | Export or crawl bindings on schedule, not per commit |
FAQ
Doesn’t Style Dictionary already detect unused tokens?
No. Style Dictionary provides usesReferences() and getReferences() for traversing the reference graph, which is exactly what you need to build alias edges, but it has no built-in unused-token rule. The aggregation across aliases, source consumption, and Figma bindings is custom. Any post or plugin claiming native unused-token reporting is describing custom logic layered on documented helpers, not a built-in audit.
What about tokens used only inside Figma files?
Count them as live, provided you derive usage from bindings. The Figma boundVariables API supports aliasing and exposes boundVariables from which usage is derivable, but there is no native unused-variables report. Export those bindings into your graph as figmaEdges so Figma-only tokens are not falsely flagged as dead. If a token is bound in Figma and nowhere else, it is still used. The right response is to decide whether Figma-only tokens belong in code at all, not to delete them automatically.
Why doesn’t this post cite a percentage of team time spent on maintenance?
Because the maintenance-cost sources this guide located were vendor-run surveys, and it does not use vendor survey statistics as evidence. It cites no independent figure it could verify. That constraint is intentional: a computable score should rest on documented tool capabilities such as the DTCG Format Module, knip issue types, and the stylelint and Figma APIs, not on a borrowed cost percentage.
The Bottom Line
Computable criteria are the precondition for treating design-system debt like engineering debt. The rule set needs only documented capabilities from tokens, lint, export analysis, and design bindings. Start with Check A in a read-only report for one sprint, confirm the manual-review queue catches the dynamic names in your codebase, and only then add the score to CI.
