Skip to content

How to keep one design system across React and Angular

Two products. Two engines. One brand.

That is the usual setup, not an edge case. Marketing is React. The internal app is Angular. Next year someone generates screens with an agent. The design system should not be rewritten each time.

The failure is tying the look to the engine.

Not the TypeScript. Not the DI. Not the router.

SharedPer engine
Colour, space, type, radiusComponent APIs (<Button> vs <ui-button>)
Layout intent (stack, inline, grid)How you bind events and forms
Button / card / input lookToast services, modal traps, focus JS
Focus rings, contrast, reduced motion defaultsFramework a11y primitives if you need them

If those shared rows live in React components, Angular has to fake them. If they live in CSS, both engines can consume the same file.

1. Copy the values. A Figma screenshot becomes hex in a React theme and again in an Angular theme. They drift in a week.

2. Pick a winner. “We will rewrite Angular in React so we can share @acme/ui.” The design system did not need that migration. The component runtime did.

3. Buy a three-framework widget kit. One Dialog implementation, three bindings, same keyboard map. That solves behaviour portability. It does not give you page layout, tokens, or a contract agents can follow. It also locks you to that kit’s component model. Different job. See Framework-agnostic UI is not a design system.

One import. Same classes. The engines only differ in how they bind.

React

export function TeamHeader() {
return (
  <header className="af-inline af-justify-between">
    <h1>Team</h1>
    <button className="af-btn">Invite</button>
  </header>
);
}

Angular

@Component({
selector: 'app-team-header',
standalone: true,
template: `
    <header class="af-inline af-justify-between">
      <h1>Team</h1>
      <button class="af-btn">Invite</button>
    </header>
  `,
})
export class TeamHeader {}

Same --af-* tokens. Same af-stack / af-btn grammar. No shared runtime. That is the point: class vs className is the whole framework gap for look and layout.

Install once:

npm install @airframeui/core
@import "@airframeui/core/core.css";

React: import the CSS from the bundle entry. Angular: add it to styles in angular.json, or @import it in styles.scss. Guides: Framework support, Angular.

Product templates should not be a soup of af-btn in one app and <ui-button> in the other unless that is deliberate.

If you already have a kit, wrap the CSS. Do not restyle a cousin.

Angular wrapper

import { Component, input } from '@angular/core';

@Component({
selector: 'ui-button',
standalone: true,
template: `
    <button
        class="af-btn"
        [class.af-is-primary]="variant() === 'primary'"
        [class.af-is-outline]="variant() === 'outline'"
      >
      <ng-content />
    </button>
  `,
})
export class UiButton {
readonly variant = input<'primary' | 'outline' | 'secondary'>('primary');
}

React wrapper

import { clsx } from 'clsx';
import type { ButtonHTMLAttributes } from 'react';

export function Button({
variant = 'primary',
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'outline' | 'secondary';
}) {
return (
  <button
    className={clsx(
      'af-btn',
      variant === 'primary' && 'af-is-primary',
      variant === 'outline' && 'af-is-outline',
      className,
    )}
    {...props}
  />
);
}

React app uses <Button>. Angular app uses <ui-button>. Both paint af-btn. Copy-paste React versions: React Recipes. Contract: Use as much as you need.

You can also skip wrappers and use af-* directly in templates. Valid. Do not run wrappers and a second .ui-* look.

Native <button>, <dialog>, and <details> go a long way. Toasts, focus traps, comboboxes, and rich menus need JS.

That JS should stay in the kit (or in React Aria / Angular CDK). Do not wait for a CSS system to invent a keyboard map. Do not duplicate a Dialog primitive in both engines and give them different padding and colour.

Look from Airframe. Behaviour from the engine’s primitive, or from native HTML. Separate design from behaviour.

Map brand once. Alias existing --color-* into --af-*, or invert after the spike.

:root {
  --af-base-primary: #0e80eb;
  --af-font-body: 'Inter', system-ui, sans-serif;
}

Ship that file in both builds. Dark, light, and high-contrast are already in the token layer. Named products use [data-brand]. Theming.

If the brand already lives in Tokens Studio or Figma, map into --af-* with Theme Studio. Generating Figma Variables from Airframe is on the roadmap. Until then, git CSS is the source both apps consume. Design tokens as the source of truth.

A third engine shows up whether you planned it or not. If React has Tailwind soup and Angular has BEM, the model invents a third look.

Give it one grammar: af-stack, af-btn, --af-*, and the published rules (@airframeui/core/rules, MCP). Same contract in both repos. AI Rules.

  1. Import @airframeui/core/core.css in both apps (or in each kit package).
  2. Map tokens. Stop adding new colours in feature CSS.
  3. Convert one shared screen (login, settings, dashboard header) to af-stack / af-inline / af-grid.
  4. Point one button in each kit at af-btn. Leave the public API stable.
  5. Repeat. Delete parallel palettes as you go.

You do not need every pattern. Tokens only is a valid stop. Use as much as you need.

Airframe is not @airframeui/react plus @airframeui/angular. There are no official runtime packages. That is intentional. The design system is the CSS contract. The engines stay yours.

If you needed one JS Dialog with identical behaviour in React and Angular, that is a different layer. Sit it on the same tokens. Do not replace the grammar with it.