$ cat articles/react-to-angular-ui-kit.md▊

Porting a React design system to Angular: 69 components in two days

The company design system shipped a React library, a Figma file and a documentation site. Our product is Angular. So we rewrote the kit natively: plain CSS tokens, attribute-selector components, Signal Forms, CDK overlays, a preview app instead of Storybook, and an accessibility and performance suite on every page. 381 commits, four waves, mostly done by parallel AI agents. Then the designer opened Figma next to it, and we learned which of our rules had been wrong.

Andrii Siuta · October 2026 · ~25 min read Angular 22 Signal Forms CSS custom properties CDK Playwright + axe
69components ported, waves 0 – 3
484icons generated, tree-shakable
0 Sass6,205 lines of plain CSS
4,284unit tests · 949 e2e pages
381commits in 32 hours
≈ 0.7 Magent tokens per component

01. The starting point

The design system is mature: a documentation site of about a hundred pages, a Figma library with foundations, components, widgets and templates, and an Apache-licensed React component library with 85 component folders, built on MUI, Emotion and a handful of React-only helpers. Its next major version dropped the theme we use. There is no Angular package and no Web Components build.

Our product is a large Angular application. Before this work it rendered Angular Material components, a PrimeNG-based table library, Bootstrap buttons and about 1,600 hard-coded hex values in component styles, with no dark mode. Every screen looked a little like the design system and none of them looked exactly like it.

The scope we agreed: port the React library's v6 line, pinned to one commit, into a new Angular library inside our Nx workspace. Charts, the code editor, experimental components and the patterns the design system itself marks "not available in code" are out. That is 85 upstream folders, planned as seven waves, of which four are done.

02. Why a native port

Three shortcuts were on the table, and each had a disqualifying cost.

  • Wrap the React components. Two frameworks in one bundle, two change-detection models, and every form control a bridge. Storybook would have been the natural catalogue, and Storybook itself depends on React.
  • Theme an existing Angular library to look like the design system. The PrimeNG family's token engine injects styles at runtime: a CSP nonce, kilobytes of JavaScript, a regenerated <style> element per component type on every theme change.
  • Copy the React kit's styling literally. It mixed four mechanisms at once: generated CSS variables, Emotion classes in the theme object, per-component variables with no shared prefix, and light-dark() in one theme's overrides, plus a parallel MUI theme for breakpoints.

So we rewrote it, importing only Angular and the CDK. Before a line was written, four read-only research passes studied how others solved the same problems, and the result was a table of what to take and what to leave:

FromTakeLeave
Angular Material (M3)component tokens unset by default, with a system fallback in static CSSits colour roles; we keep the design system's names
PrimeNG / its forkthe per-instance override idea; a typed token mapthe runtime engine and style injection
Taiga UIforceable state selectors; options through DI that merge with the parent; attribute selectors on native elements; text as signals; URL-synced controls; an axe pass on every pageglobal unencapsulated styles, LESS mixins as public API, their docs addon as a dependency
The React kittoken names, the theme selectors, helper semantics, the behaviour and API of every componentabout 140 compatibility colours, JavaScript branches on the theme name, the classes prop

03. Theming: three layers of plain CSS

  1. System tokens carry the design system's names (--ds-colors-primary, --ds-space-sm), generated into one CSS file under a theme selector, with a dark block that holds only the values that differ.
  2. Component tokens (--kit-button-height, --kit-tag-color) are never set by the kit. Each is read once with a system fallback, so a value set on any ancestor, or on the host, always wins.
  3. Defaults through DI. Every input defaults from an options token; a provider merges a partial over the parent injector's value, so a container can re-default its children.
the shape of a component stylesheet:host {
  /* private switch, set by data-size / data-variant */
  --_height: var(--kit-button-height, 32px);
  --_bg: var(--kit-button-bg, var(--ds-colors-primary));

  height: var(--_height);
  background: var(--_bg);
  transition: background calc(150ms * var(--ds-motion-scale)) ease;
}

:host([data-size='lg']) { --_height: var(--kit-button-height, 48px); }

/* every state rule exists twice: the real one, and a forced one for the preview */
:host(:hover:not([data-state])), :host([data-state='hover']) {
  --_bg: var(--kit-button-bg-hover, color-mix(in srgb, var(--ds-colors-primary) 88%, black));
}

Overrides go cheapest first: a system token on a subtree, a component token on a subtree or a host, a typed [kitTokens] input, a provided options value. A brand theme is one more token block; a one-off override is one CSS variable. Dark mode follows prefers-color-scheme until the app pins it, and a directive re-themes a subtree.

Plain CSS, by ruling

challenge

The foundation spec emitted the tokens as Sass partials with mixins, because that is what the rest of the application used. Halfway through the foundation wave I stopped it: mixins hide what a component actually sets, and custom properties already do everything the mixins did.

how we solved it

A twelve-commit conversion: one generated tokens.css, the composite font mixins dropped in favour of individual properties, and a guard that fails on any .scss, .sass or .less file, or any @use, @mixin or @include, in the kit or its preview app. A consuming app lists two stylesheets and needs no preprocessor.

Generated, never hand-edited

A Node script reads the upstream theme function out of the packed npm tarball, unpacked outside the workspace so no React dependency is added, classifies every key with a checked-in groups file, and writes the CSS and a typed TypeScript map. An unknown upstream key stops the generator, so a version bump cannot add or drop a token silently. Output is deterministic and Prettier-stable. Upstream's 323 variables became 181 on first emit: the compatibility colours and the deprecated groups did not survive. The kit added four tokens of its own: a focus-ring colour, width and offset, and a motion scale that becomes 0 under prefers-reduced-motion.

Icons follow the same rule. A second generator turns 484 upstream SVGs into one tree-shakable module of typed constants. Up to four fills become colour slots, slot 0 is currentColor, circles become arc paths, and there is no innerHTML and no sprite. An icon is a bare 1em glyph; with a label it is role="img", without one it is hidden from assistive technology.

04. What a component looks like

A native control is an attribute-selector component on the native element, so links stay links and buttons keep their form semantics. A container is an element. Behaviour alone is a directive.

the shape, placeholder names@Component({
  selector: 'button[kitButton], a[kitButton]',
  hostDirectives: [KitButtonHost],
  templateUrl: './button.component.html',
  styleUrl: './button.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KitButtonComponent {}

@Directive({
  host: {
    '[attr.data-variant]': 'variant()',
    '[attr.data-size]': 'size()',
    '[attr.aria-disabled]': 'disabled() || null',
  },
})
export class KitButtonHost {
  private readonly options = inject(KIT_BUTTON_OPTIONS);
  readonly variant = input(this.options.variant);
  readonly size = input(this.options.size);
  readonly disabled = input(false);
}

Every component folder has the same eight files, and the preview app mirrors it:

  • Kit: the component's TS, HTML, CSS and spec; a model file for its types; an options file (defaults and English labels, overridable per injector); a tokens file mapping every key to its variable, fallback and kind.
  • Preview: a page, a controls file (typed per input, with its own spec), an accessibility file, and a stress page that renders the component 500 times.
  • E2E: one keyboard script per component.

Labels are English defaults in the options, never translation keys. The kit has no dependency on a translation library; an app that translates provides its labels once per injector. Nothing in the kit is providedIn: 'root'; the one service it has is listed in the kit's providers. Two boundary rows in the lint config keep it honest: the kit's scope may import only itself, and its UI libraries may import only Angular and RxJS. Material, PrimeNG, the translation library, the state library and Bootstrap are banned by name.

05. Signal Forms, not ControlValueAccessor

Angular 22's Signal Forms let a custom control implement FormValueControl<T> (a value model) or FormCheckboxControl (a checked model). The [formField] directive binds every other state it finds: disabled, read-only, required, invalid, touched, errors. We ruled out ControlValueAccessor entirely, and the guard has a row that fails on NG_VALUE_ACCESSOR anywhere in the kit. A screen still on Reactive Forms reaches a kit control through the Signal Forms compatibility layer.

challenge

The open question going into the checkbox pilot: how does [formField] treat an attribute-selector component on a native input that also declares checked = model()? As the native element, or as the custom control?

how we solved it

The pilot answered it: as a custom control, so the component owns the DOM sync. That set the rule for the form-controls wave, "the composite is the control": the text-input component, not the inner native input, carries [formField]. A radio inside a form field throws, so the group is what binds. Eighteen classes implement the contract today. One limit is written down: a model() cannot learn about a reset to the same value.

06. Overlays without the body container

challenge

The CDK's menu trigger and dialog render into a body-level overlay container. Our theme is a subtree selector, so anything rendered outside the subtree loses its tokens: a dark-mode page opens a light-mode menu.

how we solved it

Every overlay is kit-owned and rendered in place on the CDK's inline popover support. Modals are a native <dialog> opened with showModal(), which gives the top layer, the backdrop, focus containment and Escape for free. A projected option cannot carry the CDK's listbox option directive, because DI follows the declaration tree rather than the projection, so the kit has its own listbox on FocusKeyManager. That leaves two listbox implementations side by side, which is written down as debt.

07. Accessibility as a test, not a review

  • axe on every page and every tab, in light and dark, in a Playwright suite: 949 pages at the last gate.
  • One keyboard script per component, following the WAI-ARIA Authoring Practices pattern named in the component's brief: 70 files, 467 tests.
  • A focus ring that reaches 3:1 on every surface it can sit on. Upstream hard-coded a ring that measured 2.35:1; the kit's ring is a token, and the guard computes the ratio against every background, compositing translucent and mixed colours itself.
  • A forced-colors block wherever state is carried by background alone, 24-pixel minimum targets, and every transition multiplied by the motion-scale token.
  • Performance in the same suite. 43 stress pages render 500 instances each. The DOM-node count per instance is exact, and timing fails above max(1.5 × baseline, baseline + 10 ms). The plain 1.5× bar was inside scheduling noise for sub-15 ms medians.
challenge

Some of the design system's own colour pairs fail WCAG AA. "Port the design faithfully" and "zero axe violations" cannot both hold, and changing a brand colour is not the kit's call.

how we solved it

Colours ship as designed. Every failing pair is recorded, with its measured ratio, in a shrink-only exceptions list for the design-system team; axe and the guard treat a listed pair as known and any other failure as a bug. The list grew from 16 exceptions over 25 pairs in the first wave to 59 over 125 today, and every entry is a conversation the design team can have with data. The only colour the kit fixes on its own is the focus ring.

One contrast claim was withdrawn: axe reported 4.26:1 for a set of category colours, and it was a reading taken in the middle of a 0.3-second background transition. The tests now wait for transitions to end.

08. A preview app instead of Storybook

The catalogue is a zoneless Angular app in the same workspace. I described it as an interactive Figma: the foundations (colours, icons, radii, spacing, typography), one page per component and one stress page per heavy component, with a searchable, resizable sidebar.

  • Four tabs per page: Playground, Tokens (every component token with its resolved value), Accessibility, and Code (the template for the current control values).
  • A forced-state switch (hover, active, focus-visible, disabled, invalid) written as data-state, which is why every state rule exists in two forms. A designer can look at the hover state without holding a mouse still.
  • Every control is a URL parameter. Controls are typed per input, a few names are reserved and a page that uses one fails, and an empty text parameter means the empty string while an absent one means the default. A link to a specific variant in a specific state in dark mode is a URL.
  • The colour-mode switch sits in a sticky header, because comparing a component in light and dark is the most frequent thing anyone does in it.

A second spec, written after I looked at the first version, asks for more for designers: an examples gallery, grouped controls with human labels, a Design tab with anatomy, pixel sizes, tokens resolved in both modes and the Figma link, and a canvas with surface, viewport, zoom and RTL as URL parameters. It has been deferred twice behind component waves. It is the right next thing, because of what happened in section 10.

09. How the agents did most of the work

The port ran on the same harness as the Nx migration (described in its own article): a spec per wave, a plan per batch, implementer agents with a verification command, reviewers, and a run report with a token ledger. What was specific to the kit:

A brief per component

Each implementer received one component and a contract block, written by a planner agent:

  • the upstream source paths, the component's entry in the theme file, and the Figma node id;
  • the selector, inputs, outputs, slots, host attributes and English labels;
  • styles in pixels, component tokens with their fallbacks;
  • roles and accessible names, the WAI-ARIA pattern, the keyboard table;
  • the preview controls, the stress case with its expected DOM nodes per instance, the contrast pairs to add;
  • "deviations to list", and which guards would fail if the contract were violated.

Then seven steps: read, write the kit files, write the spec, write the preview page, write the keyboard script, run the checks, commit. A value the planner had written from memory of the React library, rather than read, was flagged "check it at the click-through".

From a shared tree to a worktree each

challenge

Components are independent, so I asked for more agents in parallel. In the display-and-layout wave up to nine implementers worked in one shared checkout, and the shared build was red most of the time: one agent's half-written component broke every other agent's verification.

how we solved it

From the form-controls wave on, each task ran in its own git worktree with node_modules symlinked, its e2e server on port 4400 + N, and the dispatcher landed finished tasks by rebase and fast-forward, never while a gate was running. Gates got their own port after another project's dev server took the default one in the middle of a gate run. Planning was split the same way: four to six planner agents per wave, one per batch, their files concatenated by a script.

Reviews and gates

Per task, the conventions reviewer and the "second file" reviewer; per wave, one branch-wide code review. Every branch-wide finding was reproduced as a failing spec before it was fixed, and fixes ran in parallel lanes with disjoint paths. A wave ended with a gate: the full Jest run, the token guard, the e2e suite over every page, and the stress timings.

ReviewerW0W1W2W3
conventions reviewer, per task1.001.632.931.38
second-file reviewer, per task1.811.450.481.96
branch-wide review, per wave4.443.383.723.87

Code-changing findings per 100k tokens.

The waves

WaveScopeCommitsAgent tokensAgent hours
0 · Foundationgenerators, theming runtime, 484 icons, preview app, e2e project, guards, 8 pilot components (button family, typography, tooltip, checkbox)719.2 M10.1
1 · Display and layout26 components: accordion to grid80≈ 13 M raw13.8
2 · Form controls17 Signal Forms controls and a login-form example659.5 M8.0
3 · Overlays, lists, menus18 components: dialog, drawer, snackbar, select, dropdown, breadcrumb, bulk actions…10219.2 M15.7
Figma correctiontokens plus 25 component tasks across all 69639.4 M8.7

Agent hours add up parallel work; the commit history from the first to the last commit spans 32 hours. De-duplicated, the four component waves cost about 48 to 51 million tokens, roughly 0.7 million per component including the foundation, the preview app, the e2e suite and every review.

10. The day Figma disagreed

challenge

The rule through four waves was that where the React code and Figma differed, the code won. It was in the deviation tables from the first pilot. Then the designer put the preview app next to the Figma library. 58 of 184 system tokens differed. The primary colour in Figma is near-black; the kit's was blue, and blue survives in Figma only for links and focus. The font, the text colour, the greys, the button radius (a pill, not 6 px) and the dark-mode status colours all differed. "Faithful to the code" had produced something faithful to nobody's current design.

how we solved it

A new architecture decision, the same afternoon: Figma decides how the kit looks; the React code decides behaviour, API and structure. Then three steps: measure, encode, correct.

Measure

A token diff ran immediately, and five read-only audit agents compared every component against its Figma frame while the overlays wave kept going, without gating it. 524 rows in all, sorted into token differences, wrong token bindings, component-level differences, things missing in the kit and things the kit has that Figma does not. Of the 184 system tokens, 53 matched, 58 differed, 31 were kit-only and 42 were not comparable. Figma held another 225 variables with no kit counterpart. The work came out at 51 of 69 components needing their own changes: 18 mechanical, 25 in CSS, 8 structural, the rest token-only.

Encode

The tempting fix was a live Figma export in the generator. That would put a credential and a network call into a script that runs offline. Instead, one checked-in override file: a source block (which Figma file, when it was read, which mode is light and which dark), 56 set rows and 7 add rows, each carrying the Figma variable path. The generator applies the override, else upstream, and it refuses a row equal to upstream (so the file stays a list of differences), an unknown name, or a row with neither a Figma path nor a reason.

A later Figma version is a re-diff of one override file.

Two places keep the kit's value against Figma, and the designer is told: the full radius stays 9999 px, because Figma's value would square off a circle above 96 px; and the focus ring stays opaque, because Figma's 50 % focus colour measures 1.87 to 2.40:1 and misses 3:1. Where Figma draws no state the kit needs (focus, invalid, read-only), the kit keeps its own.

Correct

Stage 1, the tokens, landed and passed its gate: the generator first reads the override file with byte-identical output, and only then do the system tokens take Figma's values. 4,284 unit tests, 949 e2e pages and 132 timings passed on it. Stage 2 is 25 component tasks in four batches. 23 of them landed before the session's usage limit cut off five running agents, and I called a stop to review what we had before going further. Two tasks, one fix round, two batch reviews and the end gate remain, and no new wave starts until the kit and Figma agree and the designer has seen it.

11. Numbers, and what is open

MeasureValue
Components ported (of 85 upstream folders)69
System tokens, light / dark (upstream 323 / 56)190 / 41
Icons generated484
CSS in the kit, lines · Sass files6,205 · 0
Kit source, TypeScript lines (non-spec, incl. generated icons)20,365
Spec files in the kit · lines151 · 49,480
Unit tests at the last gate4,284
E2E pages (axe, light and dark) · stress timings949 · 132
Keyboard tests467
Contrast pairs checked · listed exceptions125 · 59
Commits · elapsed time381 · 32 h
Agent tokens, five run reports (raw · de-duplicated)65.7 M · ≈ 57 – 60 M

Open, in order:

  • Finish the Figma correction, then a human click-through of every page with the designer.
  • The designer-facing preview pages: examples, the Design tab, the canvas controls.
  • The brand font. Figma uses it on every text style; no file ships until the licence is confirmed, and one weight does not exist as a file at all.
  • Waves 4 to 6, specced against Figma from the start: the data-heavy components, among them the table.
  • Adoption: the first product screen that renders a kit component, which is also when the e2e suite's place in the pipeline gets decided.

12. If we did it again

  • Decide the visual authority on day one, with the designer in the room. "The code wins" was the convenient rule for agents reading a codebase, and it cost a 25-task correction. The code is the behaviour; the design file is the look.
  • Diff tokens before porting components. The token diff took minutes. Running it before the first wave would have caught the near-black primary before 69 components were built on the blue one.
  • Build the designer pages before the second wave, not after the fourth. The preview app is how a designer finds a mismatch; the better it is, the earlier they find it.
  • A worktree per agent from the first parallel wave. A shared checkout with nine writers is a red build.
  • Plain CSS from the first commit. Custom properties with fallbacks do everything a token mixin did, and a consuming app needs nothing to read them.
  • Make accessibility and performance tests, not review comments. axe on every page, a keyboard script per component, a contrast guard that does its own compositing, a stress page with an exact DOM budget. And an exceptions list for the colours that are not yours to change.
  • Encode external truth as a checked-in diff. The override file is reviewable, offline, and a later design version is one re-diff.

The port is the easy half. Agreeing on what "correct" means, and making that agreement a file a generator can read, is the half that decides whether the kit is used.