9 min read - Building a Design System with Angular: Lessons from SoarUI
Design Systems & Angular
Design systems are one of those investments that feel expensive upfront and indispensable six months later. When we started building SoarUI, our open-source Angular design system, we made decisions that saved us hundreds of hours — and mistakes that cost us weeks. This post captures the real lessons from shipping a design system used across multiple products and teams.
Whether you are starting a design system from scratch or refactoring an existing component library, these patterns and pitfalls apply to any Angular-based UI toolkit.
What you'll learn
- Why a dedicated design system pays for itself in consistency, velocity, and accessibility
- How to structure a monorepo for a multi-package Angular design system
- Token-based theming with CSS custom properties and a design token pipeline
- Component API design principles that make consumers productive
- How to bake accessibility in from day one instead of bolting it on later
- Documentation strategies with Storybook for Angular
- Versioning and breaking change management with semver and migration schematics
TL;DR
A successful Angular design system is built on four pillars: a token-based theming layer using CSS custom properties, a strict component API contract using Angular inputs and content projection, accessibility compliance tested at the component level, and a semver versioning strategy with automated migration schematics for breaking changes.
Why Build a Design System
Before writing a single component, you need a clear answer to "why not just use Material?" The reasons that justified SoarUI — and that justify most custom design systems — fall into three categories.
Consistency across products. When four applications share a brand, a shared npm package guarantees that a button looks and behaves identically everywhere. Copy-pasting component code between repos guarantees it will not.
Development velocity. A well-documented design system turns a feature ticket from "build a settings page" into "compose a settings page." Engineers stop re-inventing form layouts and focus on domain logic. On SoarUI, we measured a 35% reduction in front-end development time for new feature screens after the core component set stabilized.
Accessibility as a default. When accessibility is baked into the design system, every consuming application inherits compliant components. This is far more reliable than training every product team to add ARIA attributes themselves.
If your organization ships fewer than three distinct front-end surfaces, a shared component library inside a monorepo may be sufficient. A full design system with tokens, documentation, and versioning becomes worthwhile when you cross that threshold.
Architecture Decisions: Monorepo, CDK, and Standalone Components
Monorepo Structure
SoarUI lives in an Nx monorepo with a clear package separation:
soarui/
├── packages/
│ ├── tokens/ # Design tokens (JSON → CSS/SCSS/TS)
│ ├── core/ # Base styles, mixins, token consumers
│ ├── components/ # Angular component library
│ ├── icons/ # SVG icon system
│ └── schematics/ # ng update migration scripts
├── apps/
│ ├── docs/ # Storybook documentation site
│ └── playground/ # Development sandbox
├── tools/
│ └── token-pipeline/ # Token transformation scripts
└── nx.json
Each package publishes independently. The tokens package has zero Angular dependencies — it outputs plain CSS custom properties and TypeScript type definitions. This means non-Angular consumers (a marketing site in Astro, for instance) can still use the token layer.
Angular CDK as Foundation
Rather than building primitive behaviors from scratch, SoarUI leans heavily on the Angular CDK. Overlays, focus trapping, keyboard navigation for listboxes, and virtual scrolling all come from @angular/cdk. The CDK provides well-tested behavioral primitives without imposing visual opinions:
import { CdkMenuModule } from '@angular/cdk/menu'
import { OverlayModule } from '@angular/cdk/overlay'
@Component({
selector: 'soar-dropdown',
standalone: true,
imports: [CdkMenuModule, OverlayModule],
template: `
<button [cdkMenuTriggerFor]="menu">{{ label() }}</button>
<ng-template #menu>
<div cdkMenu class="soar-dropdown-panel">
<ng-content />
</div>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SoarDropdownComponent {
label = input.required<string>()
}
This saved us months of work on overlay positioning, focus management, and keyboard interaction — problems that are notoriously hard to get right across browsers.
Standalone Components From the Start
SoarUI was built entirely with standalone components. Every component declares its own imports explicitly, which eliminates the indirection of NgModules and makes tree-shaking more effective for consumers:
@Component({
selector: 'soar-button',
standalone: true,
imports: [SoarSpinnerComponent, SoarIconComponent],
templateUrl: './button.component.html',
styleUrl: './button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SoarButtonComponent {
variant = input<'primary' | 'secondary' | 'ghost'>('primary')
size = input<'sm' | 'md' | 'lg'>('md')
loading = input(false)
disabled = input(false)
}
Consumers add exactly the components they need to their imports array. No barrel modules, no re-export gymnastics.
Token-Based Theming
The Design Token Pipeline
Design tokens are the single source of truth for every visual decision: colors, spacing, typography, elevation, border radii, and motion. In SoarUI, tokens are authored as JSON and transformed into multiple output formats using Style Dictionary:
{
"color": {
"primary": {
"50": { "value": "#eff6ff" },
"500": { "value": "#3b82f6" },
"700": { "value": "#1d4ed8" },
"900": { "value": "#1e3a5f" }
},
"neutral": {
"100": { "value": "#f5f5f5" },
"800": { "value": "#262626" }
}
},
"spacing": {
"xs": { "value": "4px" },
"sm": { "value": "8px" },
"md": { "value": "16px" },
"lg": { "value": "24px" },
"xl": { "value": "32px" }
}
}
The pipeline outputs:
- CSS custom properties for runtime theming
- SCSS variables for build-time use within component styles
- TypeScript constants for programmatic access in component logic
/* Generated output: soar-tokens.css */
:root {
--soar-color-primary-50: #eff6ff;
--soar-color-primary-500: #3b82f6;
--soar-color-primary-700: #1d4ed8;
--soar-color-neutral-100: #f5f5f5;
--soar-color-neutral-800: #262626;
--soar-spacing-xs: 4px;
--soar-spacing-sm: 8px;
--soar-spacing-md: 16px;
}
Theming With CSS Custom Properties
Every component references tokens exclusively through CSS custom properties. No hardcoded hex values, no magic numbers:
.soar-button {
padding: var(--soar-spacing-sm) var(--soar-spacing-md);
border-radius: var(--soar-radius-md);
font-size: var(--soar-font-size-sm);
transition: background-color var(--soar-motion-duration-fast) var(--soar-motion-easing-standard);
&--primary {
background-color: var(--soar-color-primary-500);
color: var(--soar-color-on-primary);
}
}
Theme switching is then a matter of overriding the custom properties at a parent scope:
[data-theme='dark'] {
--soar-color-primary-500: #60a5fa;
--soar-color-on-primary: #1e3a5f;
--soar-color-neutral-100: #262626;
--soar-color-neutral-800: #f5f5f5;
}
No component code changes. No JavaScript runtime cost. Just CSS cascading as intended.
Component API Design Principles
A design system lives or dies by its component APIs. If the API is awkward, teams will work around it or avoid it entirely. We developed four principles over the course of building SoarUI.
Principle 1: Prefer inputs over configuration objects. Individual input() signals are easier to bind, easier to document, and easier to type-check than a single config object:
<!-- Preferred -->
<soar-button variant="secondary" size="lg" [loading]="isSaving()">Save</soar-button>
<!-- Avoid -->
<soar-button [config]="{ variant: 'secondary', size: 'lg', loading: isSaving() }">Save</soar-button>
Principle 2: Use content projection for composition. Slots via ng-content let consumers compose freely without the design system prescribing every layout:
@Component({
selector: 'soar-card',
standalone: true,
template: `
<div class="soar-card">
<div class="soar-card__header">
<ng-content select="[soarCardHeader]" />
</div>
<div class="soar-card__body">
<ng-content />
</div>
<div class="soar-card__footer">
<ng-content select="[soarCardFooter]" />
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SoarCardComponent {}
Consumers use it naturally:
<soar-card>
<h3 soarCardHeader>Invoice #1042</h3>
<p>Amount: $1,250.00</p>
<button soarCardFooter soar-button variant="primary">Pay Now</button>
</soar-card>
Principle 3: Emit granular outputs. Each meaningful user interaction gets its own output() rather than a generic event bus:
closed = output<void>()
confirmed = output<string>()
dismissed = output<void>()
Principle 4: Sensible defaults, full overrides. Every input should have a default that covers the 80% case. The remaining 20% can override explicitly. This keeps simple usage simple and complex usage possible.
Accessibility From Day One
Accessibility was not a phase in SoarUI — it was a constraint from the first component. Retrofitting accessibility onto an existing design system is an order of magnitude harder than building it in.
ARIA Attributes as Component Internals
Components manage their own ARIA attributes. Consumers should never need to add role, aria-label, or aria-expanded manually:
@Component({
selector: 'soar-accordion-item',
standalone: true,
template: `
<button
class="soar-accordion__trigger"
[attr.aria-expanded]="isExpanded()"
[attr.aria-controls]="panelId"
(click)="toggle()"
>
<ng-content select="[soarAccordionHeader]" />
</button>
<div
[id]="panelId"
role="region"
[attr.aria-labelledby]="triggerId"
[class.soar-accordion__panel--expanded]="isExpanded()"
>
<ng-content />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SoarAccordionItemComponent {
isExpanded = signal(false)
panelId = `soar-accordion-panel-${uniqueId++}`
triggerId = `soar-accordion-trigger-${uniqueId++}`
toggle() {
this.isExpanded.update((v) => !v)
}
}
Keyboard Navigation
Every interactive component supports full keyboard navigation. Dropdowns respond to arrow keys, escape closes overlays, tab order follows logical document flow. The Angular CDK handles much of this, but custom components need explicit @HostListener bindings or CDK directives for keyboard events.
Automated Accessibility Testing
We run axe-core checks inside component-level tests using jest-axe:
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
it('should have no accessibility violations', async () => {
const { container } = await render(SoarButtonComponent, {
componentInputs: { variant: 'primary' },
})
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Every component PR must pass axe checks before merging. This catches the majority of WCAG violations — missing labels, insufficient contrast, incorrect roles — before they reach production.
Documentation and Storybook Integration
A design system without documentation is a component library nobody uses. SoarUI uses Storybook for Angular as the primary documentation surface.
Each component has a story file that demonstrates every variant, state, and edge case:
export default {
title: 'Components/Button',
component: SoarButtonComponent,
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
},
} as Meta<SoarButtonComponent>
export const Primary: StoryObj<SoarButtonComponent> = {
args: { variant: 'primary', size: 'md' },
}
export const Loading: StoryObj<SoarButtonComponent> = {
args: { variant: 'primary', loading: true },
}
Beyond interactive demos, each component page includes:
- Usage guidelines — when to use this component vs alternatives
- API reference — every input, output, and content projection slot
- Accessibility notes — keyboard interactions and screen reader behavior
- Do/Don't examples — visual examples of correct and incorrect usage
The documentation site deploys automatically on every merge to main, ensuring it always reflects the latest published version.
Versioning and Breaking Changes Strategy
Design systems have a unique versioning challenge: breaking changes ripple across every consuming application. We adopted a strict strategy that balances evolution with stability.
Semantic Versioning With Clear Policies
SoarUI follows semver strictly:
- Patch — bug fixes, accessibility improvements, style tweaks that do not change the API
- Minor — new components, new optional inputs, new token values
- Major — removed components, renamed inputs, changed default behavior, token restructuring
Migration Schematics
For every major version bump, we ship Angular schematics that automate the migration. When we renamed color to variant on the button component in v3, the schematic handled it:
export function updateButtonVariantInput(): Rule {
return (tree: Tree) => {
tree.visit((filePath) => {
if (!filePath.endsWith('.html')) return
const content = tree.read(filePath)?.toString()
if (!content) return
const updated = content.replace(/\[color\]="([^"]+)"/g, '[variant]="$1"')
if (updated !== content) {
tree.overwrite(filePath, updated)
}
})
}
}
Consumers run ng update @soarui/components and the schematic applies changes automatically. This dramatically reduces the friction of major upgrades and keeps teams willing to stay current.
Deprecation Lifecycle
Before removing anything, we follow a three-step lifecycle:
- Deprecate — Mark the API with
@deprecatedJSDoc and emit a console warning in dev mode - Migrate — Ship the migration schematic in the next minor version
- Remove — Remove the deprecated API in the next major version
This gives consuming teams at least one full minor release cycle to adapt.
Lessons Learned and Mistakes to Avoid
After two years of building and maintaining SoarUI, these are the lessons we wish we had known on day one.
Start with tokens, not components. We built components first and retrofitted tokens later. This caused a painful migration where every hardcoded color and spacing value needed replacement. Define your token architecture before writing your first component.
Do not abstract too early. Our first version had a generic SoarFormField that tried to handle text inputs, selects, textareas, and date pickers through a single type input. It became unmaintainable within months. Separate components with focused APIs are always better than a god component.
Invest in contribution guidelines early. When other teams started contributing components, inconsistencies crept in — different naming conventions, different test patterns, different story structures. A thorough CONTRIBUTING guide with templates and linting rules prevents this.
Pin your peer dependencies carefully. An overly permissive peer dependency range (>=14.0.0) caused subtle breakages when consumers upgraded Angular. We now specify exact minor ranges (^18.0.0 || ^19.0.0) and test against each supported version in CI.
Measure adoption, not just coverage. Component test coverage tells you the library works. Adoption metrics — how many teams use which components, how often teams override styles — tell you whether the library is useful. We added anonymous usage telemetry (opt-in) to understand which components needed improvement.
Conclusion
Building a design system is a commitment to the long game. The architecture decisions you make in the first month — monorepo structure, token pipeline, component API conventions, accessibility standards — compound into either velocity or friction for years.
SoarUI taught us that the best design systems are opinionated about structure and permissive about composition. They enforce consistency through tokens and accessibility through automation, while giving consuming teams the flexibility to build interfaces the design system authors never imagined.
If you are considering building a design system for your Angular applications, start with the token layer, lean on the Angular CDK for behavioral primitives, and ship migration schematics with every breaking change. Your future self — and every team that depends on your library — will thank you.
Interested in how we built SoarUI or need help establishing a design system for your organization? Get in touch to discuss your needs.
Building something with Angular?
From component libraries to full product builds, we've shipped Angular at scale across dozens of projects.