library requires a disciplined architecture that treats reactivity, theming, server rendering, and accessibility as first-class concerns. The following implementation demonstrates a production-ready pattern using Angular 21 standalone components, signal APIs, CSS cascade layers, and platform-aware execution.
Step 1: Signal-First Component Architecture
Angular's input() and output() functions replace decorator-based bindings with reactive primitives. This eliminates template boilerplate and aligns component APIs with Angular's change detection model.
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
export type PanelVariant = 'neutral' | 'accent' | 'warning';
export type PanelSize = 'compact' | 'standard' | 'expanded';
@Component({
selector: 'app-panel',
standalone: true,
imports: [CommonModule],
template: `
<section
[class]="'panel panel--' + variant() + ' panel--' + size()"
[attr.data-panel-id]="panelId()"
>
<header class="panel__header">
<h2 class="panel__title">{{ title() }}</h2>
@if (showClose()) {
<button
class="panel__close"
(click)="closeRequest.emit()"
aria-label="Close panel"
>
Γ
</button>
}
</header>
<div class="panel__content">
<ng-content />
</div>
</section>
`,
styleUrls: ['./panel.component.css']
})
export class PanelComponent {
readonly title = input.required<string>();
readonly variant = input<PanelVariant>('neutral');
readonly size = input<PanelSize>('standard');
readonly showClose = input<boolean>(false);
readonly panelId = input<string>(`panel-${Math.random().toString(36).slice(2, 9)}`);
readonly closeRequest = output<void>();
readonly isAccent = computed(() => this.variant() === 'accent');
}
Why this works: Signal inputs automatically track dependencies and trigger change detection only when values mutate. The computed() signal derives state without manual subscriptions. Template bindings use signal functions directly, eliminating the need for OnPush strategies or manual detectChanges() calls.
Step 2: CSS Custom Property Token System
Design tokens must live in the CSS cascade, not in build tools. CSS custom properties enable runtime theme switching, inherit through the DOM tree, and respect user preferences like prefers-color-scheme.
/* tokens.css */
:root {
--app-color-bg: #ffffff;
--app-color-surface: #f8f9fa;
--app-color-text: #111827;
--app-color-accent: #2563eb;
--app-color-accent-hover: #1d4ed8;
--app-radius-md: 0.5rem;
--app-spacing-unit: 0.25rem;
--app-font-stack: system-ui, -apple-system, sans-serif;
}
[data-theme="dark"] {
--app-color-bg: #0f172a;
--app-color-surface: #1e293b;
--app-color-text: #e2e8f0;
--app-color-accent: #3b82f6;
--app-color-accent-hover: #60a5fa;
}
[data-contrast="high"] {
--app-color-text: #ffffff;
--app-color-accent: #00ffff;
--app-color-accent-hover: #00cccc;
}
.panel {
background-color: var(--app-color-surface);
color: var(--app-color-text);
border-radius: var(--app-radius-md);
padding: calc(var(--app-spacing-unit) * 4);
font-family: var(--app-font-stack);
}
.panel--accent {
border-left: 4px solid var(--app-color-accent);
}
Why this works: Tokens are scoped to :root and overridden via data attributes. This allows theme providers to swap tokens dynamically without JavaScript style manipulation. The cascade ensures components inherit values naturally, and prefers-color-scheme can be layered on top without conflict.
Step 3: SSR-Safe DOM Execution
Direct DOM access breaks server rendering. Angular provides isPlatformBrowser to guard platform-specific logic. Wrapping this in a reusable utility prevents hydration mismatches.
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
export function usePlatformGuard() {
const platformId = inject(PLATFORM_ID);
const isBrowser = isPlatformBrowser(platformId);
return {
isBrowser,
executeIfBrowser: <T>(fn: () => T): T | undefined => {
if (isBrowser) return fn();
return undefined;
},
safeQuerySelector: (selector: string): HTMLElement | null => {
if (!isBrowser) return null;
return document.querySelector(selector);
}
};
}
Why this works: Server rendering executes in a Node environment where document and window are undefined. Guarding DOM operations prevents ReferenceError crashes and ensures hydration matches the server-generated DOM tree. This utility centralizes platform checks, reducing duplication across components.
Step 4: Accessibility-First Markup
WCAG 2.1 AA compliance requires semantic HTML, proper ARIA roles, and logical heading hierarchy. Components must enforce structure, not just visual appearance.
@Component({
selector: 'app-alert',
standalone: true,
template: `
<div
role="alert"
[attr.aria-live]="polite() ? 'polite' : 'assertive'"
class="alert alert--{{ type() }}"
>
<h3 class="alert__heading">{{ heading() }}</h3>
<p class="alert__message">{{ message() }}</p>
</div>
`
})
export class AlertComponent {
readonly type = input<'info' | 'success' | 'error'>('info');
readonly heading = input.required<string>();
readonly message = input.required<string>();
readonly polite = input<boolean>(true);
}
Why this works: role="alert" ensures screen readers announce dynamic content. aria-live controls announcement priority. Semantic elements (h3, p) maintain document outline integrity. This pattern prevents common violations like aria-label on non-interactive elements or missing landmark regions.
Pitfall Guide
1. Token Cascade Pollution
Explanation: Defining CSS custom properties at the component level without scoping causes theme leakage. Child components inherit unintended values, breaking visual consistency.
Fix: Use CSS cascade layers (@layer tokens, base, components) and scope tokens to :root or explicit theme containers. Never define design tokens inside component stylesheets.
Explanation: Calling document.querySelector, window.innerWidth, or setTimeout without platform guards triggers server-side crashes and hydration mismatches.
Fix: Wrap all browser-only APIs in isPlatformBrowser checks. Use Angular's afterNextRender or afterRender for safe DOM initialization. Centralize platform logic in injectable utilities.
3. ARIA Role Misapplication
Explanation: Adding role="button" to a <div> without keyboard event handlers or focus management violates WCAG. Screen readers expect interactive behavior matching the role.
Fix: Prefer native HTML elements (<button>, <a>, <input>). When using <div> or <span>, implement tabindex="0", keydown handlers, and role attributes together. Validate with axe-core or Lighthouse.
4. Signal-Driven Change Detection Storms
Explanation: Creating signals inside templates or loops causes unnecessary re-evaluations. Unbounded signal chains trigger multiple change detection cycles per render.
Fix: Initialize signals in component constructors or ngOnInit. Use computed() for derived state. Avoid signal creation in ngAfterViewInit or event handlers. Profile with Angular DevTools to detect redundant cycles.
5. Focus Trap Neglect in Overlays
Explanation: Modals, drawers, and dialogs that don't trap focus allow keyboard users to navigate outside the overlay, breaking interaction flow and accessibility compliance.
Fix: Implement focus trapping using FocusMonitor or custom keydown listeners. Restore focus to the trigger element on close. Test with keyboard-only navigation and screen readers.
6. Hydration Mismatch from Async State
Explanation: Components that render different content on server vs client (e.g., based on localStorage or navigator) cause hydration errors and layout shifts.
Fix: Defer client-only rendering using @if (isBrowser) or defer blocks. Use Angular's provideClientHydration() to manage state reconciliation. Avoid platform-dependent logic in initial render paths.
7. Over-Nesting Theme Providers
Explanation: Wrapping applications in multiple theme context providers creates conflicting CSS variable scopes and increases bundle size with redundant JavaScript.
Fix: Use a single theme provider at the application root. Leverage CSS custom property inheritance instead of JavaScript state for theming. Keep theme switching declarative and DOM-driven.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Runtime theme switching required | CSS custom properties + data attributes | Zero JavaScript overhead, instant cascade updates | Low (CSS-only) |
| Build-time theming only | SCSS variables + compile-time generation | Faster initial load, no runtime parsing | Low (build step) |
| SSR + CSR hybrid app | Signal inputs + isPlatformBrowser guards | Prevents hydration mismatches, maintains reactivity | Medium (guard overhead) |
| CSR-only application | Standard decorators + platform checks optional | Simpler setup, acceptable if SEO not required | Low |
| High-contrast / accessibility focus | Semantic HTML + ARIA roles + focus management | Meets WCAG 2.1 AA, reduces legal/compliance risk | Medium (testing overhead) |
Configuration Template
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideClientHydration()
]
};
/* theme.css */
@layer tokens, base, components;
@layer tokens {
:root {
--app-color-bg: #ffffff;
--app-color-surface: #f8f9fa;
--app-color-text: #111827;
--app-color-accent: #2563eb;
--app-radius-md: 0.5rem;
--app-spacing-unit: 0.25rem;
}
[data-theme="dark"] {
--app-color-bg: #0f172a;
--app-color-surface: #1e293b;
--app-color-text: #e2e8f0;
--app-color-accent: #3b82f6;
}
}
@layer base {
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--app-color-bg); color: var(--app-color-text); }
}
@layer components {
.panel { background: var(--app-color-surface); border-radius: var(--app-radius-md); }
}
// angular.json (excerpt)
{
"projects": {
"your-library": {
"architect": {
"build": {
"options": {
"outputPath": "dist/your-library",
"tsConfig": "tsconfig.lib.json",
"assets": ["src/assets"],
"styles": ["src/styles/theme.css"]
}
}
}
}
}
}
Quick Start Guide
- Initialize the library workspace: Run
ng generate library your-ui --standalone=true --prefix=app to create a standalone component library with Angular 21 defaults.
- Configure signal inputs: Replace decorator bindings in your first component with
input() and output() functions. Verify change detection stability using Angular DevTools.
- Add token styles: Create a
theme.css file with CSS custom properties scoped to :root. Import it into your library's build configuration and apply tokens to component styles.
- Guard platform access: Wrap all
document, window, or navigator calls in isPlatformBrowser checks. Use afterNextRender for safe DOM initialization.
- Validate accessibility: Run
npx axe-cli src/ and ng build --configuration=production to catch ARIA violations, heading skips, and hydration mismatches before publishing.