DEV Community

Cover image for I built an Angular component library on signals — the decisions, the numbers, and the bugs behind it
Diego Maestro Navarro
Diego Maestro Navarro

Posted on

I built an Angular component library on signals — the decisions, the numbers, and the bugs behind it

For the past few months I've been building @dmaster/ui — an open-source component library for Angular — and today I'm publishing it for the first time.

The elevator pitch, in numbers I can back up:

Signals-first APIs, zero zone.js · a full component catalog, from buttons to a virtual-scroll table and a command palette · themed entirely through CSS custom properties (9 prebuilt themes, one variable re-skins everything) · WCAG 2.1 AA enforced by CI — a single axe-core violation fails the PR · ~102 kB gzipped for the whole tree-shaken library, ~4 kB for a button · runs on Angular 20, 21 and 22 · MIT.

ng add @dmaster/ui
Enter fullscreen mode Exit fullscreen mode

But this post isn't a feature tour — the docs render every component live, which does that job better. This is the set of engineering decisions I made and why, plus the three lessons that each cost me an afternoon and taught me something real about how modern Angular works. Those are the parts I always wish library authors wrote down.

Why another Angular library?

Fair question. Angular Material, PrimeNG and Taiga UI are excellent and battle-tested — I wasn't trying to replace them. I wanted a library built from scratch on the newest Angular primitives, with a specific design language (flat, very rounded, vivid) and accessibility treated as a hard gate rather than a nice-to-have.

It was also, honestly, the best way I know to learn a framework deeply: rebuild its hard parts yourself. Every overlay, every roving-tabindex keyboard interaction, every focus trap — you don't really understand them until you've shipped one that works for a screen reader.

So the goal was never "biggest catalog." It was modern foundations, correct defaults, no runtime baggage.

Decision 1: signals-first, zoneless from day one

Every component's public API is built on Angular signals — input(), output(), model(), computed() — with OnPush everywhere, standalone components, no NgModules, no decorators, and no zone.js anywhere. Change detection is driven by signals, so the whole library runs in a provideZonelessChangeDetection() app today — the docs site itself is zoneless and prerendered.

And because the library never had zone.js or NgModules to shed, staying current has been painless: the peer range is ^20 || ^21 || ^22 — and that's not just a declared range. Before widening it, I packed the npm tarball and built it into fresh apps on each major to verify the compiled output actually links cleanly on all three.

Here's what that buys you in practice. The button has async states built in — a boolean drives the spinner, the disabled state and a polite live region for screen readers. You just feed it a signal:

import { Component, inject, signal } from '@angular/core';
import { DmButtonComponent } from '@dmaster/ui';
import { SettingsApi } from './settings-api';

@Component({
  selector: 'app-save',
  imports: [DmButtonComponent],
  template: `
    <dm-button
      color="primary"
      [loading]="saving()"
      loadingLabel="Saving changes"
      (clicked)="save()">
      Save
    </dm-button>
  `,
})
export class SaveComponent {
  private readonly api = inject(SettingsApi);
  protected readonly saving = signal(false);

  async save() {
    this.saving.set(true);
    await this.api.persist();
    this.saving.set(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

And when a boolean isn't enough, there's a full state machine on the same button — [state] walks idle → loading → success → error, flashing the icon and announcing successLabel/errorLabel as it goes. Two tiers of the same API: the ergonomic shortcut for the 90% case, the explicit machine when you need it.

No NgZone, no setTimeout hacks, no "why didn't the view update" debugging. State is a signal, the view derives from it, done.

Lesson 1: in a zoneless app, microtasks don't wait for your template

Building overlays without zone.js surfaces assumptions you didn't know you had. Here's the one that stumped me on the dm-select filter input.

When you open the dropdown, the search box inside should get focus. My first version did the obvious thing — queue the focus right after opening the panel:

openPanel() {
  this.setOpen(true);
  queueMicrotask(() => this.filterRef()?.nativeElement.focus()); // ❌ never fires
}
Enter fullscreen mode Exit fullscreen mode

In a zone-based app this works by accident. In a zoneless app it silently never focuses — the microtask runs before the change detection cycle that mounts the CDK overlay, so filterRef() is still undefined when the callback executes. There's no error. The input just never gets focus, and you're left staring at a dropdown wondering why typing does nothing.

The fix is to stop guessing about timing and hook the overlay's own lifecycle. CDK's connected overlay emits (attach) exactly when the panel lands in the DOM — that's the only race-free moment. This is the actual shipped code, doc comment included:

/**
 * The overlay just attached its template — the only moment when focusing the
 * filter input is race-free (a microtask from `openPanel()` always loses to
 * zoneless change detection and finds no input).
 */
protected onOverlayAttach(): void {
  if (this.filterable()) {
    queueMicrotask(() => this.filterRef()?.nativeElement.focus());
  }
}
Enter fullscreen mode Exit fullscreen mode
<ng-template
  cdkConnectedOverlay
  [cdkConnectedOverlayOpen]="open()"
  (attach)="onOverlayAttach()">
  <!-- panel -->
</ng-template>
Enter fullscreen mode Exit fullscreen mode

The lesson generalized across the whole library: never assume a microtask lands after a template has rendered. Bind to real lifecycle signals — afterNextRender, an overlay's attach, an effect() — not to timing luck. Once I internalized that, a whole class of "works in dev, flakes in prod" bugs disappeared. The select's spec suite now pins this behavior so it can never regress.

Decision 2: every form control is a real ControlValueAccessor

The forms family — select, autocomplete, date-picker, color-picker, slider, rating, number-input, OTP, checkbox, switch, radio-group, toggle-group, search-field — are all proper ControlValueAccessors. They work with template-driven and reactive forms out of the box, no wrappers:

form = new FormGroup({
  plan: new FormControl('pro'),
  notifications: new FormControl(true),
});

plans = [
  { value: 'free', label: 'Free' },
  { value: 'pro', label: 'Pro' },
  { value: 'team', label: 'Team' },
];
Enter fullscreen mode Exit fullscreen mode
<dm-select [formControl]="form.controls.plan" [items]="plans" />
<dm-switch [formControl]="form.controls.notifications" ariaLabel="Email notifications" />
Enter fullscreen mode Exit fullscreen mode

dm-select alone does single/multiple selection, inline filtering, option groups, select-all, and async server-driven loading with infinite scroll and debounced server-side search — all through that same CVA surface. The date-picker covers single dates and ranges using nothing but the native Date and Intl — no date library, so locale-aware month names, week starts and even digit systems come free from the platform.

CVA was also a deliberate bet on the future: Angular's experimental Signal Forms are designed to interoperate with CVA controls, so supporting them later is additive — not a rewrite.

Decision 3: theming is just CSS custom properties

This is the part I'm happiest with. There's no theme-compilation step and no SCSS mixin you have to call. Every color is a CSS variable, and the whole library re-skins from a handful of them.

Change --dm-primary in any scope — globally, per subtree, or on a named theme — and the entire palette re-derives: hover states, subtle fills, accessible text colors, even the brand gradient on the logo.

/* one variable re-skins the entire library */
:root { --dm-primary: #7c3aed; }
Enter fullscreen mode Exit fullscreen mode

Here's that claim, live on the docs site — every component re-derives from the one variable (watch the ng add command follow the palette too):

Switching palettes live — every component re-derives from one CSS variable

theme-switch.gif INTO THE EDITOR HERE, then delete this comment line -->

How does one variable stay accessible for an arbitrary brand color? The derived tokens use OKLCH relative color syntax with pinned lightness. Because lightness dominates WCAG contrast, a random brand color still lands close to AA automatically. These are the actual shipped lines — the comments record the hand-picked hex each formula was calibrated to replace:

--dm-primary-hover: oklch(from var(--dm-primary) 49.2% calc(c * 0.859) h); /* was #005bc4 */
--dm-success-hover: oklch(from var(--dm-success) 62.21% calc(c * 0.841) h); /* was #12a150 */
Enter fullscreen mode Exit fullscreen mode

The lightness is a fixed, calibrated number per token (different per theme — dark mode raises it instead), and only chroma and hue track your brand color. Two details in there are load-bearing. The chroma multiplier isn't cosmetic — multiplying c down keeps the derived color in gamut; feed the raw c through and the browser clips per channel, which visibly distorts the hue instead of reducing saturation.

Lesson 2: the one-character bug that invalidates an entire color

The second load-bearing detail is that the hue channel is just h, unitless. During that calibration, when I wanted to nudge a hue, I wrote what looked obviously correct:

/* ❌ silently invalidates the ENTIRE color */
--dm-primary-hover: oklch(from var(--dm-primary) 49.2% calc(c * 0.859) calc(h + 4deg));
Enter fullscreen mode Exit fullscreen mode

Inside relative color syntax, h resolves to a number, not an angle. Adding 4deg mixes a number and an angle in the same calc(), which makes the whole color invalid — and CSS fails silently, falling back to the inherited color. No warning, no devtools complaint. Just a subtly wrong page. The fix is three characters shorter:

/* ✅ number + number */
--dm-primary-hover: oklch(from var(--dm-primary) 49.2% calc(c * 0.859) calc(h + 4));
Enter fullscreen mode Exit fullscreen mode

On top of that cascade sit the higher theming levels: light/dark/auto built in, named custom themes you register and switch at runtime (provideDmasterUI({ themes: … }) + ThemeService.setTheme('midnight')), and per-component re-skinning — every component also exposes its own design tokens (--dm-button-radius, --dm-table-header-bg, … 300+ of them across the library, documented per component in its README). Nine themes ship prebuilt — cobalt, ember, forest, grape, iris, ocean, rose, slate, sunset — and ng generate @dmaster/ui:theme <name> scaffolds your own. The docs site has a palette picker that swaps the tokens live: the whole page, logo and even the favicon re-theme with no rebuild.

Because components consume only semantic tokens — there are zero [data-dm-theme='dark'] branches inside component styles — a custom theme is nothing more than a block of CSS variables.

Decision 4: quality claims must be CI gates, or they're marketing

This is the differentiator I care about most. "Accessible" in a README is a claim; a pipeline that fails the PR is a property. Four gates run on every pull request:

  • Accessibility — every route of the docs site is prerendered and scanned with axe-core (WCAG 2.1 A + AA), in both light and dark mode, including open overlays. Zero violations required to merge.
  • Visual regression — Playwright pixel-diffs every route in both themes, inside a pinned container image so font rendering can't produce phantom diffs.
  • Consumer packaging — the CI packs the actual npm tarball and builds it into an isolated Angular app that resolves @dmaster/ui like a real consumer (exports map, .d.ts, styles, secondary entry points). The docs app imports source, so without this gate the published package was never actually exercised.
  • Publishing — publint on the package, and every release ships to npm with signed provenance (Sigstore) from the library's public repo.

The a11y gate forced real design decisions, not cosmetic ones: separate -text tokens per color so tinted fills keep ≥4.5:1 in both themes, native indeterminate on checkboxes instead of aria-checked="mixed", roving tabindex on trees, menus and toggle groups, :focus-visible rings and ≥44px touch targets everywhere. 799 tests back all of it, and the library respects prefers-reduced-motion globally.

Lesson 3: I almost concluded my own library didn't tree-shake

I wanted a per-component size audit in CI, so I pointed esbuild at the built package, imported one button, and measured. Result: ~74 kB gzipped. For a button. Importing any single component gave the same ~74 kB.

I nearly filed it as "the library doesn't tree-shake." The real story: ng-packagr ships Angular libraries as partial compilation — the FESM is full of ɵɵngDeclareComponent metadata that only the Angular linker turns into tree-shakeable code. Bundle it without the linker and nothing can be dropped; the measurement is meaningless, not the library broken.

The correct pipeline is the one the Angular CLI runs: the linker plus its babel plugins (adjust-static-class-members, elide-angular-metadata, pure-toplevel-functions), then esbuild. With that in place, the honest numbers:

Import Tree-shaken, gzipped
DmButtonComponent ~4.0 kB
DmCardComponent ~1.4 kB
DmSelectComponent ~10.9 kB
DmTableComponent ~12.8 kB
Entire library ~102 kB

If you maintain an Angular library and have ever measured its tree-shaken cost with a plain bundler — your numbers might be as wrong as mine were.

On dependencies: the only runtime peers are @angular/cdk (overlays), @angular/forms and rxjs — all first-party Angular ecosystem. No icon-font requirement, no CSS-in-JS runtime, no utility framework.

What's inside

The catalog spans eight categories — and it keeps growing:

  • Primitives — badge, avatar, icon, kbd, skeleton, spinner
  • Layout — card, accordion, divider
  • Feedback — progress, alert
  • Buttons — button, button-group, copy-button
  • Forms — select, autocomplete, date-picker, color-picker, slider, rating, number-input, file-upload, OTP, toggle-group, checkbox, switch, radio-group, search-field, form-field, error
  • Navigation — tabs, breadcrumbs, pagination, stepper
  • Data display — table (with virtual scroll), tree, timeline, empty-state
  • Overlays (CDK) — tooltip, dialog, toast, menu, popover, command palette, drawer

The component gallery at dmasterui.com/components — every tile is a live, rendered preview

A few pieces I had particular fun with: the command palette (⌘K with fuzzy filtering), the table's virtual-scroll mode (a div-grid with role="table" inside a CDK viewport, because the CDK wrapper can't live inside a native <table>), and the icon component's three modes — Material Symbols ligatures with variable-font axes, a registered SVG set, or your own projected <svg>.

The docs site renders every component live, in three languages, with a playground and an API table per component — and it's the permanent smoke test: it dogfoods the library on every page, and its prerender fails the build if anything breaks SSR-safety.

Getting started

ng add @dmaster/ui
Enter fullscreen mode Exit fullscreen mode
import { Component } from '@angular/core';
import { DmButtonComponent } from '@dmaster/ui';

@Component({
  selector: 'app-demo',
  imports: [DmButtonComponent],
  template: `<dm-button color="primary">Ship it</dm-button>`,
})
export class DemoComponent {}
Enter fullscreen mode Exit fullscreen mode

Or try it without installing anything: there's a StackBlitz starter wired up and ready.

It's early — and that's the point

This is 0.10.3, pre-1.0. The API is stabilizing but not frozen, and this is the first time I'm showing it to anyone. If you try it and something is missing, awkward or broken, that's genuinely the most useful thing you could tell me right now — this is the window where feedback still shapes the 1.0.

I'm one person building this in the open, and I'll read every issue.

A question to leave you with: what's the first thing you check before adopting a UI library — the docs, the bundle size, the a11y story, the maintenance activity? And what would a pre-1.0 library have to show you to earn a try on a real project? Tell me in the comments — the answers will directly shape what 1.0 prioritizes.

If you build something with it, I'd love to see it. A ⭐ on GitHub genuinely helps a solo project get seen.

Top comments (0)