Canon DS — a design system that had to survive contact with a product
Self-initiated concept. Not a client project. Design and build: Canon Chong · ~2 days · Plain CSS + React 19, Next.js 16
The problem
Most design systems fail the same way, and it is not the way people expect.
They are not badly designed. They are built in isolation — a specimen page, every component in every state, all of it looking finished. Then the first real product tries to use it and discovers that the token layer supports theming right up until a component hard-coded a hex, that the CSS collides with the host app's reset, or that half the components need props nobody anticipated. The team works around it. Within a quarter the system is a suggestion.
The specimen is not the deliverable. A product running on the system is the deliverable, and until one does, everything in the library is a guess.
Constraints I set
- No framework dependency.
@import "@canon/ds"has to give you the whole system in a plain
HTML page. If the system only works inside React, it is a component library, not a design system — and it cannot be sold to a team that does not use React.
- Two independent axes. Theme (dark/light) and density (comfortable/compact) both have to work,
and changing one must not touch the other.
- A real product has to consume it before it ships. Non-negotiable, and the most useful
constraint of the four.
- The system is the only vocabulary. No arbitrary values in the consuming app. If a value cannot
be expressed in tokens, the token layer is wrong and gets fixed — that is the finding, not the workaround.
Decisions
Two token layers, and only one of them is speakable
64 primitives are the raw ramps: --primary-500, --neutral-800, --success-400. No component is allowed to reference one. 37 semantics are the entire vocabulary a component gets: --bg-surface, --text-secondary, --border-default, --data-negative.
That indirection is the whole reason theme switching works. Light mode is not a second stylesheet and not a set of overrides — it is the same 37 names pointed at different primitives. A component written against semantics follows automatically because it never knew what colour it was using.
The rule has teeth precisely because it is annoying. The moment you let one component read --neutral-800 directly, that component has hard-coded a decision it does not own, and it will be the one that looks broken in light mode six months later when nobody remembers why.
Density is a separate axis, not a smaller theme
Compact changes six tokens: --row-h, three field heights, and two padding values. It does not touch colour, type sizes, or the space scale.
This is the part most systems get wrong by making "compact" a wholesale shrink. A dense table needs tighter rows; it does not need smaller text, and shrinking both is how dense interfaces become unreadable. Keeping the axes independent means a trading terminal can run compact without sacrificing legibility, and the same components serve both.
The component layer is plain CSS; React is a thin wrapper
570 lines of CSS, 144 class names, every one prefixed ds-, wrapped in @layer components.
The prefix is not cosmetic. The original specimen styled bare table, th and td — perfectly fine in a standalone page, and poison the moment a host app imports the package and finds its own tables restyled. A design system that leaks selectors gets uninstalled.
The @layer components wrapper means app utilities always win without a specificity fight. It also means the layer order has to be declared before the imports, because CSS orders layers by first appearance — which is the same failure mode that had silently flattened the agent console before this work started. Here it is designed in rather than patched.
The 14 React components compose class names, forward refs, and carry the accessibility plumbing. They are a convenience. The CSS is the product.
The plumbing that usually gets skipped
Every one of these is tedious enough that hand-rolled versions omit it, and each is a real defect:
- Field wires
htmlFor,id,aria-describedbyandaria-invalidthrough context from one
useId. Doing it manually at each call site is where accessibility quietly rots.
- Checkbox syncs
indeterminateonto the DOM node via a ref, because HTML has no attribute for
it. Miss that and your "select all" header renders unchecked.
- Modal traps focus, restores it on close, handles Escape, and locks scroll. Without the trap,
Tab walks into the page behind the overlay — invisible but still focusable.
- Tabs implements roving tabindex. Without it a ten-tab bar costs ten Tab presses to walk past.
- Toast announces errors assertively and everything else politely. Making every toast assertive
means a "Saved" confirmation cuts off the sentence someone was mid-way through reading.
Table is presentational only
No sorting logic, no selection state, no pagination behaviour. All of it stays in the app.
A design system that owns data logic stops being reusable the first time someone needs server-side sorting. The component provides the sort affordance and the aria-sort wiring; deciding what sorted means is the product's job.
The docs site's theme toggle is a test, not a feature
Flipping it changes two attributes on <html>. 37 semantic tokens re-resolve and no component is told anything happened.
Which makes it a live assertion: any component that hard-coded a hex or a pixel height will visibly fail to follow. Every page of the documentation is running that test continuously, in front of whoever is reading it.
The preference is applied by a synchronous script in <head> rather than an effect. Reading it in an effect renders the default theme first and snaps — a flash of the wrong theme on every load, which is exactly the kind of detail that makes a polished system feel cheap.
What consuming it actually found
I refactored the AI agent console — a finished product, already deployed — onto the system.
It surfaced a missing component. The runs table needed a status treatment and the system only had StatusPill: solid, uppercase, dotted. Four solid pills stacked down a dense table is shouting. The gap was in the system, not the app, so I added StatusDot — same five tones, no fill, for contexts where a column heading already frames what the value means. Building the library in isolation would never have produced it.
It also found a place not to use the system. The console's runs table kept its own full-bleed markup instead of adopting ds-table, which is a contained card-style shell. A console list wants the width. Forcing it would have been worse, and a system you cannot decline is not a system, it is a cage.
And it removed a lie. The console's filter pills and search box looked interactive and did nothing. Swapping them for the DS toggle group made that impossible to leave alone — a control with a pressed state that does nothing is worse than a static button. They work now, and the cost summary follows the filter, because a total that ignores the current view reads as an answer to the question you just asked.
Result
A system in two layers, consumed by a shipped product, documented on a site that tests itself every time someone flips a switch.
What it demonstrates: that I can build the layer other people build on top of — and that I know the difference between a component library that photographs well and a system that survives a second product.
Concept work, built to explore the problem. No affiliation with any vendor.