Accessibility
WCAG 2.2 AAA isn't a checklist bolted onto one screen — it's built into the design system. The patterns below are live: try them with a keyboard, and with a screen reader if you have one. Every example uses the same @foo/ui components as the rest of the app, and the whole project is checked automatically with axe in CI — the AAA + best-practice ruleset, every route in all three themes, zero violations.
Skip link
The first thing in the tab order is a "Skip to main content" link (try pressing Tab right after a fresh load). It's visually hidden until focused, then jumps keyboard users past the sidebar straight to the page — so they don't tab through the whole navigation on every route.
View source — skip link, markup and styles
Two halves of one pattern: the link is the first thing in the shell's DOM so it's the first tab stop, and its target is the <main> the router fills. The styles pull it off-screen until :focus, then bring it back — visible only to the keyboard user who needs it.
<a class="skip-link" href="#main">Skip to main content</a>
<div class="shell">
<header class="shell__header">
<div class="shell__lead">
<!-- The hamburger lives at the head of the bar; it shows only ≤768px (see mobile-nav.scss). -->
<foo-mobile-nav />
<a class="brand" routerLink="/">
<img
class="brand__mark"
src="miguel-2026-brandmark.png"
alt=""
aria-hidden="true"
width="28"
height="28"
/>
<span class="brand__text">
<span class="brand__name">Enterprise Angular Showcase</span>
<span class="brand__by">by Miguel Carino</span>
</span>
</a>
<!-- Promoted from the bottom of /overview to a persistent header entry (one shared data source). -->
<foo-ecosystem-menu />
</div>
<div class="shell__actions">
<a class="companion" [href]="portfolioUrl" target="_blank" rel="noopener noreferrer">
A companion to <strong>miguelcarino.com</strong> <span aria-hidden="true">↗</span>
</a>
<label class="theme-switch">
<span class="theme-switch__label">Theme</span>
<!--
[selected] on each option, not [value] on the select: a native <select>'s value is
read before @for has rendered its options, so a [value] binding desyncs on reload —
the control shows Light while the page is the persisted theme. The option owns its own
selected state, so it lands correctly whatever order the DOM settles in.
-->
<select foo-select (change)="onThemeChange($event)">
@for (t of themes; track t) {
<option [value]="t" [selected]="t === theme.theme()">{{ t | titlecase }}</option>
}
</select>
</label>
</div>
</header>
<div class="shell__body">
<nav class="sidebar" aria-label="Sections">
@for (group of nav; track group.label) {
<div class="sidebar__group">
<h2 class="sidebar__group-title">{{ group.label }}</h2>
<ul class="sidebar__links">
@for (link of group.links; track link.path) {
<li>
<a
class="sidebar__link"
[routerLink]="'/' + link.path"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: link.path === '' }"
>{{ link.label }}</a
>
</li>
}
</ul>
</div>
}
</nav>
<main id="main" class="content" tabindex="-1">
<router-outlet />
</main>
</div>
</div>
Focus management & trapping
The dialog is the native <dialog> element opened with showModal(), so the platform traps focus inside it, makes the background inert, and closes it on Escape. Open it, tab around (focus stays inside), then press Escape — focus returns to the button you opened it from.
View sourcedialog.ts
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
input,
model,
output,
viewChild,
} from '@angular/core';
import { Button } from '../button/button';
export type DialogSize = 'sm' | 'md' | 'lg';
let dialogId = 0;
/*
* A thin wrapper over the native `<dialog>` element. The whole point of this component is a
* refusal: I am not going to hand-roll a focus trap. Every modal library that does has the
* same bug list — Tab escapes to the page behind it, Escape doesn't close, the background
* stays clickable and screen-reader-readable, focus doesn't return to the trigger on close.
* `showModal()` solves all of that in the platform: it traps focus inside the dialog, wires
* Escape-to-close, marks the rest of the document inert, and exposes `aria-modal` semantics.
* The browser does this more correctly than userland JavaScript ever will, and it keeps doing
* it correctly as the spec evolves. So the job here shrinks to one honest task — keep an
* Angular signal in sync with whether the element is open. That's the only thing left to own.
*
* <foo-dialog [(open)]="confirmOpen" heading="Delete project" (closed)="onClosed()">
* Are you sure? This cannot be undone.
* <div dialogFooter>…actions…</div>
* </foo-dialog>
*
* `open` is a two-way `model`, which is the part that earns its keep: the host can both drive
* the dialog (set it true to open) and observe it (read it back when the user closes). The
* trap is forgetting the second direction. The user can close this dialog three ways I don't
* control — Escape, a click on the backdrop, the native `close()` — and if I only ever push
* the model down into the element, those closes leave the model stuck on `true`. So the
* binding has to be a loop: an `effect` reconciles the model into the element's real state,
* and the native `close` event reconciles back the other way. Honest in both directions.
*/
@Component({
selector: 'foo-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Button],
template: `
<dialog
#dialog
class="foo-dialog"
closedby="any"
[attr.aria-labelledby]="headingId"
(close)="onNativeClose()"
>
<div class="foo-dialog__panel foo-dialog__panel--{{ dialogSize() }}">
<header class="foo-dialog__header">
<h2 class="foo-dialog__title" [id]="headingId">{{ heading() }}</h2>
<button
foo-button
variant="ghost"
size="sm"
class="foo-dialog__close"
aria-label="Close dialog"
(click)="close()"
>
×
</button>
</header>
<div class="foo-dialog__body">
<ng-content />
</div>
<footer class="foo-dialog__footer">
<ng-content select="[dialogFooter]" />
</footer>
</div>
</dialog>
`,
styleUrl: './dialog.scss',
})
export class Dialog {
readonly open = model(false);
readonly heading = input('');
readonly dialogSize = input<DialogSize>('md');
readonly closed = output<void>();
protected readonly headingId = `foo-dialog-title-${dialogId++}`;
private readonly dialogRef = viewChild.required<ElementRef<HTMLDialogElement>>('dialog');
constructor() {
/*
* Reconcile the model into the element. The guards on `el.open` aren't defensive
* padding — they're load-bearing. `showModal()` throws an InvalidStateError if the
* dialog is already open, and `close()` fires a redundant `close` event if it's already
* shut. Checking the element's own `open` flag before acting makes both calls idempotent,
* so this effect can re-run as often as the signal graph wants without side effects.
*/
effect(() => {
const el = this.dialogRef().nativeElement;
if (this.open()) {
if (!el.open) {
el.showModal();
}
} else if (el.open) {
el.close();
}
});
}
/*
* The close button doesn't touch the model. It calls the element's `close()` and stops
* there, because that fires the native `close` event, which `onNativeClose` already
* listens for. Every way of closing — button, Escape, backdrop — funnels through that one
* event, so the state-sync lives in exactly one place instead of being duplicated per path.
*/
protected close(): void {
this.dialogRef().nativeElement.close();
}
/*
* The single drain for every close. Whatever closed the dialog, the platform fires `close`
* and we land here: pull the model back to false (guarded so we don't loop with the effect)
* and emit `closed` for the host. This is the "reconcile back" half of the two-way loop.
*/
protected onNativeClose(): void {
if (this.open()) {
this.open.set(false);
}
this.closed.emit();
}
}
Mobile navigation drawer
Narrow the window below 768px and a hamburger appears in the header; it opens this app's own nav as a drawer. It's the focus-trap lesson above, applied: the drawer is a native <dialog> opened with showModal(), so the menu is its own exemplar. Open it and Tab — focus stays inside; press Escape — it closes and focus returns to the hamburger. The toggle carries aria-expanded/aria-controls, section groups start collapsed, and picking a link closes the drawer.
View source — the mobile nav drawermobile-nav.ts
The same refusal as the dialog: I won't hand-roll a focus trap when the platform has one. showModal() traps focus, makes the page inert, wires Escape, and restores focus to the trigger — so the component only owns the two things it doesn't give for free, body-scroll lock and the aria-expanded state on the hamburger.
import {
ChangeDetectionStrategy,
Component,
ElementRef,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { DOCUMENT, TitleCasePipe } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import type { Theme } from '@foo/tokens';
import { Select, ThemeService } from '@foo/ui';
import { NAV } from './navigation';
import { Ecosystem } from './ecosystem';
let drawerId = 0;
/*
* The mobile navigation drawer. On desktop the sidebar is always there; below 768px it would
* otherwise stack the whole catalog — 15 links under 8 headers — as a wall above the content.
* This replaces that with a hamburger in the header and a drawer holding the same nav.
*
* The a11y is the point — this is the accessibility showcase, so its own mobile menu has to be
* exemplary. Rather than hand-roll a focus trap (the same refusal as foo-dialog), the drawer is
* a native `<dialog>` opened with `showModal()`: the platform traps focus inside it, wires
* Escape-to-close, marks the rest of the document inert, exposes aria-modal, and returns focus
* to whatever was focused when it opened — the hamburger. The only things left to own are the
* two bits the platform doesn't give for free: body-scroll lock while open, and the aria-expanded
* state on the trigger. The toggle carries aria-expanded + aria-controls; section groups start
* collapsed (native <details>, so keyboard-operable for free); selecting a link closes the drawer.
*/
@Component({
selector: 'foo-mobile-nav',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, RouterLinkActive, Select, TitleCasePipe],
template: `
<button
#trigger
type="button"
class="mobile-nav__toggle"
[attr.aria-expanded]="open()"
[attr.aria-controls]="drawerElId"
aria-label="Navigation menu"
(click)="openDrawer()"
>
<span class="mobile-nav__bars" aria-hidden="true"></span>
</button>
<dialog
#drawer
[id]="drawerElId"
class="mobile-nav__drawer"
closedby="any"
aria-label="Sections"
(close)="onNativeClose()"
>
<div class="mobile-nav__panel">
<div class="mobile-nav__head">
<span class="mobile-nav__title">Sections</span>
<button
type="button"
class="mobile-nav__close"
aria-label="Close navigation menu"
(click)="close()"
>
<span aria-hidden="true">×</span>
</button>
</div>
<label class="mobile-nav__theme">
<span class="mobile-nav__theme-label">Theme</span>
<select foo-select (change)="onThemeChange($event)">
@for (t of themes; track t) {
<option [value]="t" [selected]="t === theme.theme()">{{ t | titlecase }}</option>
}
</select>
</label>
<nav class="mobile-nav__nav" aria-label="Sections menu">
@for (group of nav; track group.label) {
<details class="mobile-nav__group">
<summary class="mobile-nav__group-title">{{ group.label }}</summary>
<ul class="mobile-nav__links">
@for (link of group.links; track link.path) {
<li>
<a
class="mobile-nav__link"
[routerLink]="'/' + link.path"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: link.path === '' }"
(click)="close()"
>{{ link.label }}</a
>
</li>
}
</ul>
</details>
}
</nav>
<div class="mobile-nav__ecosystem">
<span class="mobile-nav__section-title">Ecosystem</span>
<ul class="mobile-nav__links">
@for (item of ecosystem(); track item.href) {
<li>
<a
class="mobile-nav__link"
[href]="item.href"
target="_blank"
rel="noopener noreferrer"
(click)="close()"
>{{ item.label }} <span aria-hidden="true">↗</span></a
>
</li>
}
</ul>
</div>
</div>
</dialog>
`,
styleUrl: './mobile-nav.scss',
})
export class MobileNav {
private readonly document = inject(DOCUMENT);
protected readonly nav = NAV;
// The cross-surface links (federated host, remotes, Storybook, blog) — the header's Ecosystem
// disclosure is squeezed off the bar on mobile, so the drawer carries them instead. Env-resolved
// (dev localhost / prod subdomains), SSR-safe — see the Ecosystem service.
protected readonly ecosystem = inject(Ecosystem).menu;
protected readonly theme = inject(ThemeService);
protected readonly themes = this.theme.available;
protected readonly open = signal(false);
protected readonly drawerElId = `mobile-nav-drawer-${drawerId++}`;
private readonly drawerRef = viewChild.required<ElementRef<HTMLDialogElement>>('drawer');
constructor() {
/*
* Reconcile the open signal into the element, and lock body scroll alongside. The guards on
* `el.open` are load-bearing, not padding: showModal() throws if the dialog is already open
* and close() fires a redundant close event if it's already shut — checking the element's own
* flag first makes both idempotent, so the effect can re-run freely.
*/
effect(() => {
const el = this.drawerRef().nativeElement;
const body = this.document.body;
if (this.open()) {
if (!el.open) {
el.showModal();
}
// showModal() makes the page inert but doesn't stop it scrolling under the drawer.
body.style.overflow = 'hidden';
} else {
if (el.open) {
el.close();
}
body.style.overflow = '';
}
});
}
protected openDrawer(): void {
this.open.set(true);
}
protected onThemeChange(event: Event): void {
this.theme.setTheme((event.target as HTMLSelectElement).value as Theme);
}
// Close through the element so every path (button, Escape, backdrop) drains one close event.
protected close(): void {
this.drawerRef().nativeElement.close();
}
// The single drain. The platform returns focus to the trigger; we just sync the signal back.
protected onNativeClose(): void {
if (this.open()) {
this.open.set(false);
}
}
}
Live regions
Toasts render into an aria-live region, so a screen reader announces them without focus ever moving. Politeness follows severity: a success is polite, an error is assertive so it interrupts.
View source — the live-region toaster
The service is the only state — a signal queue with a timer per toast. The outlet reads that signal and renders the region; politeness is derived from the variant, so an error gets aria-live="assertive" and interrupts while a success waits its turn at polite.
import { Injectable, signal } from '@angular/core';
import type { AlertVariant } from '../alert/alert';
export type ToastVariant = AlertVariant;
export interface Toast {
readonly id: number;
readonly variant: ToastVariant;
readonly heading?: string;
readonly message: string;
readonly duration: number;
}
// What a caller hands `show` — the id is assigned by the service.
export interface ToastInput {
readonly variant: ToastVariant;
readonly heading?: string;
readonly message: string;
// ms before the toast auto-dismisses; defaults to 5000.
readonly duration?: number;
}
// Options for the convenience helpers — message is passed separately.
export type ToastOptions = Omit<ToastInput, 'variant' | 'message'>;
const DEFAULT_DURATION = 5000;
/*
* Holds the queue of active toasts and owns their lifecycle. A toast is added by
* `show` (or a convenience helper), announced by the `foo-toaster` outlet, and removed
* either by its auto-dismiss timer or an explicit `dismiss(id)`. The signal is the only
* state; the outlet reads it and never mutates it.
*/
@Injectable({ providedIn: 'root' })
export class ToastService {
private readonly _toasts = signal<readonly Toast[]>([]);
readonly toasts = this._toasts.asReadonly();
private nextId = 0;
private readonly timers = new Map<number, ReturnType<typeof setTimeout>>();
// Queue a toast; returns its id. Auto-dismisses after `duration` ms (default 5000).
show(toast: ToastInput): number {
const id = this.nextId++;
const duration = toast.duration ?? DEFAULT_DURATION;
const entry: Toast = {
id,
variant: toast.variant,
heading: toast.heading,
message: toast.message,
duration,
};
this._toasts.update((list) => [...list, entry]);
if (duration > 0) {
this.timers.set(
id,
setTimeout(() => this.dismiss(id), duration),
);
}
return id;
}
// Remove a toast and clear its pending timer.
dismiss(id: number): void {
const timer = this.timers.get(id);
if (timer !== undefined) {
clearTimeout(timer);
this.timers.delete(id);
}
this._toasts.update((list) => list.filter((t) => t.id !== id));
}
success(message: string, opts?: ToastOptions): number {
return this.show({ ...opts, variant: 'success', message });
}
info(message: string, opts?: ToastOptions): number {
return this.show({ ...opts, variant: 'info', message });
}
warning(message: string, opts?: ToastOptions): number {
return this.show({ ...opts, variant: 'warning', message });
}
danger(message: string, opts?: ToastOptions): number {
return this.show({ ...opts, variant: 'danger', message });
}
}
Keyboard navigation
Composite widgets follow the WAI-ARIA authoring practices — one tab stop, then arrow keys move within. Focus the tabs and press ←/→; open the menu and use ↑/↓, Home/End, and Escape.
Arrow keys move between tabs (with wraparound); focus follows selection, and only the active tab is in the page tab order (roving tabindex).
The button owns aria-haspopup/aria-expanded; the list is a role="menu" with roving tabindex. Selecting returns focus to the trigger.
Every control is a real native element with a programmatic label, an aria-describedby hint/error, and aria-invalid on failure.
View sourcetabs.ts
import {
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
contentChildren,
effect,
model,
viewChildren,
} from '@angular/core';
import { Tab } from './tab';
/*
* A WAI-ARIA tabs widget. The children — projected `foo-tab` elements — are read with
* `contentChildren`, and from them we render a `role=tablist` of `role=tab` buttons above the
* projected panels. Selection lives in a two-way `selectedIndex` model so a parent can drive
* or observe the active tab.
*
* The keyboard model is the part most homegrown tab strips get wrong, and it's worth slowing
* down on. A naive implementation gives every tab `tabindex="0"`, which means Tab stops on
* each one — eight tabs become eight stops a keyboard user has to walk past to reach the
* panel. The ARIA pattern is the opposite: the tablist is ONE tab stop. Exactly one tab holds
* `tabindex="0"` (the active one); the rest hold `-1`, which keeps them focusable by script
* but skips them in the Tab order. That's "roving tabindex" — the single 0 roves to whichever
* tab is active. Tab moves you past the whole group; the arrow keys move you within it. You
* can see the roving directly in the template: `[tabindex]="i === activeIndex() ? 0 : -1"`.
*
* This is the "automatic activation" flavor of the pattern: arrows move selection (with
* wraparound), Home/End jump to the ends, and focus follows selection so the panel under the
* focused tab is the one that shows. The alternative — manual activation, where arrows only
* move focus and you press Enter to select — is the right call when switching tabs is
* expensive (a network fetch per panel). These panels are already projected, so automatic is
* the friendlier choice.
*/
@Component({
selector: 'foo-tabs',
changeDetection: ChangeDetectionStrategy.OnPush,
/*
* No `imports: [Tab]` here on purpose. `Tab` is used as a query token for contentChildren,
* not as a tag this template renders, so it isn't a template dependency. Listing it would
* compile clean but mislead the next reader into thinking we render `<foo-tab>` ourselves.
*/
template: `
<div class="foo-tabs__list" role="tablist">
@for (tab of tabs(); track tab.tabId; let i = $index) {
<button
#tabButton
class="foo-tabs__tab"
type="button"
role="tab"
[id]="tab.tabId"
[attr.aria-controls]="tab.panelId"
[attr.aria-selected]="i === activeIndex()"
[class.foo-tabs__tab--active]="i === activeIndex()"
[tabindex]="i === activeIndex() ? 0 : -1"
(click)="select(i)"
(keydown)="onKeydown($event)"
>
{{ tab.label() }}
</button>
}
</div>
<div class="foo-tabs__panels">
<ng-content />
</div>
`,
styleUrl: './tabs.scss',
})
export class Tabs {
readonly tabs = contentChildren(Tab);
readonly selectedIndex = model(0);
private readonly tabButtons = viewChildren<ElementRef<HTMLButtonElement>>('tabButton');
/*
* `selectedIndex` is host-controlled, so I can't trust it to be in range — a parent can
* set it to 9 when there are three tabs, or a tab can be removed out from under a stale
* value. `activeIndex` is the clamped, always-valid truth the template renders from, while
* `selectedIndex` stays the raw model the host owns. Deriving one from the other keeps the
* invalid state from ever reaching the DOM instead of guarding for it at every read site.
*/
protected readonly activeIndex = computed(() => {
const count = this.tabs().length;
if (count === 0) {
return 0;
}
return Math.min(Math.max(this.selectedIndex(), 0), count - 1);
});
constructor() {
/*
* The panels need to know which one of them is showing, but a projected `foo-tab` has no
* idea where it sits in the list — it's just content, passive by design. So selection is
* owned here and pushed down: whenever the active index or the set of tabs changes, this
* effect flips each child's `active` flag. One source of truth at the top, children that
* only ever react. That's deliberately the inverse of letting each panel manage itself,
* which is how two tabs end up both thinking they're visible.
*/
effect(() => {
const active = this.activeIndex();
this.tabs().forEach((tab, i) => tab.active.set(i === active));
});
}
protected select(index: number): void {
if (index !== this.selectedIndex()) {
this.selectedIndex.set(index);
}
}
protected onKeydown(event: KeyboardEvent): void {
/*
* The wraparound is the whole point of the modulo. ArrowRight off the last tab lands on
* the first; ArrowLeft off the first wraps to the last — `(current - 1 + count) % count`
* adds `count` first so the index never goes negative before the modulo. A keyboard user
* should never hit a dead end at either end of the strip. The handler is only ever bound
* on rendered tab buttons, so `count` is guaranteed > 0 here and the modulo is safe.
*/
const count = this.tabs().length;
const current = this.activeIndex();
let next: number | null = null;
switch (event.key) {
case 'ArrowRight':
next = (current + 1) % count;
break;
case 'ArrowLeft':
next = (current - 1 + count) % count;
break;
case 'Home':
next = 0;
break;
case 'End':
next = count - 1;
break;
default:
return;
}
event.preventDefault();
this.select(next);
this.focusTab(next);
}
/*
* Selecting a tab updates the model, but the model doesn't move DOM focus — and with roving
* tabindex it has to. The newly active tab is now the only one with `tabindex="0"`; the tab
* the user was just on dropped to `-1`. If I don't explicitly move focus onto the new tab,
* focus is left sitting on an element that's now skipped in the Tab order, and the next Tab
* press jumps somewhere unexpected. So arrow navigation always pairs select() with focusTab().
*/
private focusTab(index: number): void {
// focus() scrolls the element into view in most engines, but when the tablist is its own
// horizontal scroller (the mobile strip) a programmatic focus can leave the tab clipped at an
// edge. The explicit scrollIntoView keeps arrow navigation reaching tabs that started offscreen.
const button = this.tabButtons()[index]?.nativeElement;
button?.focus();
button?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
}
}
Built into the tokens
View source — the a11y defaults in the reset_reset.scss
These guarantees aren't sprinkled per component — they live once in the token reset. :focus-visible draws the ring from a single --foo-color-focus token so every surface focuses identically, and the prefers-reduced-motion block collapses animation app-wide for users who asked the OS to calm motion down.
/*
* A small, opinionated reset. Just enough to make the token system the baseline —
* box-sizing, sane margins, themed body, and accessibility defaults that are easy to
* forget (focus-visible, reduced motion, readable line length on text).
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
min-height: 100vh;
font-family: var(--foo-font-sans);
font-size: var(--foo-text-base);
line-height: var(--foo-leading-normal);
color: var(--foo-color-text);
background-color: var(--foo-color-bg);
-webkit-font-smoothing: antialiased;
}
/*
* Keyboard users get a ring; pointer users don't. The ring itself is a token so every
* surface focuses the same way.
*/
:focus-visible {
outline: 2px solid var(--foo-color-focus);
outline-offset: 2px;
}
img,
picture,
svg,
video {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
// Respect the user. If they've asked the OS to calm motion down, we listen.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}