How I got our designers writing production code
Creativity loves constraints, and it turns out so does Claude.
In the olden days, designers built flows in Figma. Every state would be there: the empty one, the error one, the one where the name is too long. Then it went into a ticket, and the ticket went into a queue, and a fortnight later an engineer built something that was nearly it. Priorities moved on before the polish did, so the polish was the first thing to go, then sometimes the flow itself.
Everyone in that story was doing their job well. The design was good, the ticket was clear, the engineer was fast. But nobody was fully satisfied with the end result.
I work at a company that builds software for clinical trials. A fortnight spent perfecting keyboard navigation on a complex form is a fortnight not spent enabling a medical monitor to assess if an adverse event is related to the treatment or not.
After two years of shipping the bare minimum, things started to hurt. Users stopped tolerating our inconsistencies. There were five different modals that could take weeks to consolidate. Some screens would break when the viewport halved, and fixing one would likely break another.
Hypothesis:
Our first experiment:
"Claude, build a multiselect input component according to these designs."
We even threw in some popular React skills from the community. If all the hype were true, that's the whole post, job done, engineers quietly updating their CVs.
So why didn't this work?
If you give a model complete freedom to implement a solution, it reaches for whatever's popular, not what's already idiomatic in your codebase. You can sprinkle your CLAUDE.md with "avoid overly long functions" and "minimise state", but real code runs on tribal knowledge and domain terms, not LeetCode affirmations.
Remember: LLMs were trained on the full spectrum of code quality. And we all have our repos full of skeletons.
Then it hit me: what if we were so strict about what we asked for, that Claude had no excuse to deviate? What if we had linters screaming if an import reached across layers? What if we took separation of concerns to a level that even Sonnet could do things right?
What did we actually want?
I sat down with everyone that could benefit from this idea. As always, the best solution came from the merging of many wonderful minds.
Our designers told me they worked in layers - raw tokens, semantic tokens, primitives, components, patterns. I experimented with what I'd learned from incredible devs like developerway and Josh Comeau. And the end result?
Raw tokens
Fonts, colours, spacing, named after what they are rather than what they are for.
/* Gray */:root { --gray-white: #ffffff; --gray-25: #fcfcfd; --gray-50: #f9fafb; --gray-100: #f2f4f7; --gray-200: #e4e7ec; --gray-300: #d0d5dd; --gray-400: #98a2b3;}
Semantic tokens
The same values in our native tongue: primary, secondary, surface, border. Switching between light and dark mode is as simple as defining new values for the same variable.
/* Light mode (default) */:root { --sem-text-role-primary: var(--gray-900); --sem-text-role-secondary: var(--gray-700); --sem-text-role-tertiary: var(--gray-550); --sem-text-role-disabled: var(--gray-400); --sem-text-role-link: var(--indigo-medium); --sem-text-role-link-hover: var(--indigo-dark);}
Tailwind variants
Ask a multi-part component for its small variant, and tv works out what small means for each of its parts.
const badge = tv({ base: "inline-flex gap-xxs px-xxs rounded-corner-radius-s", variants: { size: { base: "h-6 text-label-base-sentence-case", small: "h-5 text-label-small-sentence-case", }, color: { danger: "bg-surface-status-danger-subtle text-text-status-danger", success: "bg-surface-status-success-subtle text-text-status-success", info: "bg-surface-status-info-subtle text-text-status-info", }, }, defaultVariants: { color: "info", size: "base" },});
Primitive components
Mostly threading though Base UI components, or our own carefully constructed headless components. This layer strictly provides structure, web semantics, and accessibility. Focus traps, keyboard behaviour, ARIA wiring; the parts that are really hard to get right, already solved.
export { Menu } from "@base-ui/react/menu";export { Dialog } from "@base-ui/react/dialog";export { Select } from "@base-ui/react/select";export { Tooltip } from "@base-ui/react/tooltip";
Components
The primitive wearing the variants, exported as something a person can use. Every part it is made of gets its own slot, so a caller picks a component and never a class name. This is the layer the application is allowed to know about.
export function DropdownMenu({ children, ...rest }: Props) { const slots = menu(); // <- Tailwind variants return ( <Menu.Root {...rest}> <Menu.Popup className={slots.popup()}> {children} </Menu.Popup> </Menu.Root> );}
Application
Imports from the components layer passes the server state to be rendered. It can reach for flex and grid to put things next to each other, but it's not allowed to interfere with branding.
export function SiteFilter({ studyId }: Props) { const { sites, isLoading } = useEnrolmentBySite(studyId); return ( <DropdownMenu> <DropdownMenu.Trigger> <Button trailing={ChevronRight}>Filters</Button> </DropdownMenu.Trigger> <DropdownMenu.Content> <DropdownMenu.Search placeholder="Search sites" /> {isLoading && <Spinner />} {sites.map((site) => ( <Section key={site.id} title={site.name}> <Badge color="success">{site.enrolled} enrolled</Badge> </Section> ))} </DropdownMenu.Content> </DropdownMenu> );}
This project wasn't without its difficulties:
- The migration took some time, and most of it was a person's time: a designer ported the components across one by one, with Claude doing the mechanical translation once the pattern for each was set.
- Standards required alignment. Convincing yourself a rule is useful is easier than convincing your entire team. This was a fantastic exercise in knowledge sharing as everyone's instincts became codified in skills.
- The ROI needed to be visible. The business value of a refactor is rarely obvious, so we needed to demonstrate positive outcomes quickly. We prioritised building the foundations so everyone could start building components. Feature work was accelerating - there was excitement!
Did it work?
Boy did it.
Velocity and quality grew in tandem - unheard of. Within weeks our designers owned the design system. They build, polish, and maintain components. An engineer still reviews and owns what merges, but a set of constantly improving automated review skills catch the tedious stuff, so that review is spent on the edge case intricacies rather than pointing out linting issues.
Engineers could focus on modelling the data and the API, and stop worrying about which tailwind class they're meant to be using for a primary button on a table.
Most importantly users' trust in the product was restored. They could predict what each piece of UI would do, without second-guessing themselves because a button was sometimes a hyperlink that would lose all their progress.
We stopped having to worry about the tools, and put all our time and energy into the problem to solve. And isn't that what it's all about?
Why did this work?
- Claude listens to linters. When claude would try to import across layers, use a
classNamein application code, or use rawpxto position something, a virtual slap on the wrist put it back in line. - Visual regressions were localised and loud. Every component had a story for every state. Changing a token would highlight every altered interface, allowing designers and engineers to understand the extent of their change.
- The rubric ships in the skill file, not in someone's head. It loads with every prompt targeting the design system. It turns a judgement call into a deterministic lookup, and a lookup is the one thing a model never talks itself out of.
Applied in order, stopping at the first match. Five of the six answers are some form of "you already have what you need". Situation Decision Content change inside a fixed layout Slot ( children)Visual variant within a closed, bounded set Enumerated prop ( size,tone)Caller needs named parts in a flexible arrangement Compound subcomponent ( Foo.Header)Different ARIA role, focus return, or semantic New component beside the existing one Three or more booleans controlling layout or behaviour Refactor to a discriminated union or compound Only one or two consumers want it Wait. Rule of three before promotion - Every component is the same shape. Multi-part components are always a root that holds the context and named subparts that read it, exported with
Object.assign. One shape means a review is a diff against a pattern rather than a fresh argument, and a component whose content changes later grows a subpart instead of another boolean.const FooContext = createContext<FooCtx | null>(null); function Root({ children }: RootProps) { return ( <FooContext.Provider value={{ /* shared state */ }}> <div className={slots.root()}>{children}</div> </FooContext.Provider> );} function Header({ children }: { children: ReactNode }) { /* reads ctx */} function Body({ children }: { children: ReactNode }) { /* reads ctx */} export const Foo = Object.assign(Root, { Header, Body });
The guardrails were the feature
I ran a workshop with a suite of non-technical users to truly stress test this new way of working. You never know what a real user is going to do, so I held my breath. Someone wanted to build a new dashboard, another wanted to add a filter menu to our tables. Complex problems with so many variables.
But the feedback says it all:
[we were] able to contribute to the code base directly, empowering us to make small fixes, whilst ensuring guardrails are in place so we don't do anything silly.
These brilliant minds were no longer constrained by engineering capacity. They still didn't know their var's from their const's, but they didn't need to.
If you teach a man to fish etc. etc.
Today a designer handed me the finished screen instead of a ticket. The pressure's on to make my code as good as theirs.
Join my mailing list
If you'd like more of my internal monologue and shower thoughts every now and then, just add your email here.
A confirmation link will be sent to your email. If you get fed up with me, a two-click unsubscribe link will be in the footer of every email. How I secure your email