All work

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

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.

and changing one must not touch the other.

constraint of the four.

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:

useId. Doing it manually at each call site is where accessibility quietly rots.

it. Miss that and your "select all" header renders unchecked.

Tab walks into the page behind the overlay — invisible but still focusable.

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.

Open Canon DSStart a conversation