At Wonderlabs I built and maintained the team's Angular component library on top of Angular Material, for a credit card PWA serving 50,000+ users. Storybook came into it only a little, near the end. The goal was a system that stayed in step with the Figma library, that the three of us on the front end could consume without fighting the API, and that stayed consistent as features shipped. Here is what bit me and what I would do differently.
Angular Material 17+ Theming Architecture
Material 17.2 introduced MD3 CSS custom properties theming as experimental, and it became stable in Angular Material 18. This replaced the older Sass mixin approach and it is a genuine improvement. You define a theme using define-theme, which produces scoped CSS custom properties. Component styles consume those properties. No more deep Sass variable hunting when a color needs to change. The Wonderlabs library predates that: it lived on Angular 15 and the Sass API shown below. The token lesson that follows applies to both. The component snippets further down are illustrative and use current Angular syntax (@if blocks, required inputs) and current Material token names; on Angular 15 you would write *ngIf and plain @Input().
// styles/_theme.scss
@use '@angular/material' as mat;
$primary-palette: mat.define-palette(mat.$indigo-palette, 700, 400, 900);
$accent-palette: mat.define-palette(mat.$amber-palette, A200);
$light-theme: mat.define-light-theme((
color: (
primary: $primary-palette,
accent: $accent-palette
),
typography: mat.define-typography-config(
$font-family: 'Inter, sans-serif',
$headline-4: mat.define-typography-level(2rem, 2.5rem, 700),
$body-1: mat.define-typography-level(1rem, 1.5rem, 400)
),
density: 0
));
@include mat.all-component-themes($light-theme);Copy that block onto a current Material and it will not compile. Angular Material 18 renamed the M2 API with an m2- prefix: mat.define-palette became mat.m2-define-palette, mat.define-light-theme became mat.m2-define-light-theme, mat.define-typography-config became mat.m2-define-typography-config, and the built-in palettes gained the prefix too, so mat.$indigo-palette is now mat.$m2-indigo-palette. Same arguments, new names. If you are starting a theme today, skip M2 entirely and use the M3 define-theme API.
Tokens First, Components Second
The mistake I made early: I started with components. It felt productive. Months in, we had a button, a form field, a modal, and a data table, all built before the token structure was settled, and formalizing tokens meant going back through every one of them to replace hardcoded values. Token-first is not theoretical advice, it is scar tissue. The general rule: without semantic tokens, a brand color change is a hunt for hardcoded hex values across every component; with them, it is one edit.
The token layer sits in a single file imported before any component styles, whether you write it by hand or generate it from Figma Variables with Style Dictionary. Raw values map to semantic names. Semantic names are what components consume. When the brand color shifts, you change one raw value and the semantic cascade does the rest.
/* styles/_tokens.scss */
:root {
/* Raw color scale */
--color-primary-500: #3949ab;
--color-primary-700: #283593;
--color-primary-900: #1a237e;
--color-error-600: #d32f2f;
--color-error-700: #c62828;
/* Semantic tokens, the only layer components reference */
--color-action-primary: var(--color-primary-700);
--color-action-primary-hover: var(--color-primary-900);
--color-text-primary: #1a1a2e;
--color-text-secondary: #4a4a6a;
--color-surface: #ffffff;
--color-surface-variant: #f4f4f8;
/* Spacing scale */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Border radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-pill: 9999px;
}Extending Material Without Hiding Its API
A common mistake is wrapping Material components in fat wrappers that expose a completely custom API. The consuming developer now has to learn your wrapper and has lost access to all the Material inputs they actually need. They start punching through the abstraction with ViewChild and ElementRef. You have made things worse.
The better approach is thin wrappers that add the specific behavior you need and let Material do the rest. For variant customization, CSS custom property overrides scoped to a host attribute work cleanly without touching the component's TypeScript surface area.
// ds-button.component.ts: a thin wrapper, not a replacement
@Component({
selector: 'ds-button',
standalone: true,
imports: [MatButtonModule],
host: { '[attr.variant]': 'variant' },
template: `
<button mat-flat-button [disabled]="disabled">
<ng-content />
</button>
`
})
export class DsButtonComponent {
@Input() variant: 'primary' | 'secondary' | 'destructive' | 'ghost' = 'primary';
@Input() disabled = false;
}
// ds-button.component.scss
:host([variant='destructive']) {
--mat-button-filled-container-color: var(--color-error-600);
--mat-button-filled-label-text-color: white;
&:hover button {
--mat-button-filled-container-color: var(--color-error-700);
}
}Form Fields with Consistent Error Handling
In a credit card product, validation error messages are not a UX afterthought. Once the wording has been reviewed, it should not drift from screen to screen. A form field wrapper in the design system centralizes that: one place where all error message logic lives, keyed for i18n, so nobody on the team can accidentally ship off-script copy.
@Component({
selector: 'ds-form-field',
standalone: true,
imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, TranslateModule],
template: `
<mat-form-field appearance="outline" [subscriptSizing]="'dynamic'">
<mat-label>{{ label | translate }}</mat-label>
<!-- The wrapper renders the input itself: mat-form-field cannot find
a matInput that is projected into it through ng-content -->
<input matInput [formControl]="control" />
@if (control.hasError('required') && control.touched) {
<mat-error>{{ 'validation.required' | translate }}</mat-error>
}
@if (control.hasError('minlength') && control.touched) {
<mat-error>
{{ 'validation.minLength' | translate:{ min: control.errors?.['minlength']?.requiredLength } }}
</mat-error>
}
</mat-form-field>
`
})
export class DsFormFieldComponent {
@Input({ required: true }) label!: string;
@Input({ required: true }) control!: FormControl;
}What Actually Matters at Scale
- Version the library as a separate npm package from day one. Even if it starts in a monorepo, versioning creates accountability for breaking changes
- Document every component variant before implementing. They are living documentation and acceptance criteria simultaneously
- WCAG 2.1 AA is non-negotiable in financial applications. ARIA attributes, keyboard navigation, and contrast ratios are baked into components, not left to product teams
- Semantic token names survive rebrands. --color-action-primary survives a brand refresh. --color-indigo-700 does not
- Every new component added to the library went through a design review and a second developer's code review, even on a three-person team. Without process, the DS becomes a dumping ground
- When you rename a component or change an API, provide a schematic so consuming teams are not stuck on old versions indefinitely
A design system is not a component library. It is a shared language between design and engineering. If designers are not contributing to it and developers are not consulting it first, it has already failed.
Angular Material handles accessibility, theming infrastructure, and component behavior at a level of quality I am not going to replicate from scratch. Building on top of it is the right call. The real investment is in the token system, the component API design, the documentation, and the governance that keeps it alive and trusted. Get those right and the technical implementation is the easy part.
References
- Angular Material theming. https://material.angular.io/guide/theming
- Material Design 3 tokens. https://m3.material.io/foundations/design-tokens/overview
- Storybook for Angular. https://storybook.js.org/docs/angular/get-started/introduction
- Angular CLI schematics. https://angular.dev/tools/cli/schematics