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.

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.

html
<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.

Confirm transfer

Focus is trapped here while the dialog is open. The button below is reachable; the page behind it is not.

View sourcedialog.ts
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()"
          >
            &times;
          </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.

ts
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">&times;</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.

ts
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).

View sourcetabs.ts
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

What you get for free
Status and text colours meet AAA 7:1 contrast in light, dark, and the brand theme. Keyboard focus always shows a visible ring (:focus-visible). The base stylesheet honours prefers-reduced-motion, collapsing animation for users who ask the OS to calm motion down.
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.

css
/*
 * 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;
  }
}