# Airport matrix

> A live world clock in an airport dot matrix, with colour, time-zone and focus controls.

Five cities share one quiet airport board. Every lit dot follows the real time in its city; change the colour, switch to a digital face, search time zones or start a focus session. The full clock and its preferences stay inside the component.

A React component by Bruno Delorence: https://brunodelorence.com/components/airport-matrix. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Data.

## Anatomy

1. **Mode tabs**: A real tablist for Watch, Focus and World time: arrows, Home and End move between them.
2. **Preferences button**: Opens the panel with the face, the five colours and the focus duration.
3. **Expand button**: A toggle that makes the board taller, 500 px instead of 392; Escape brings it back.
4. **Board**: The tab panel. Its time updates every second while it is on screen and stops when it scrolls away or the tab is hidden.
5. **City row**: One city’s time and name painted as 5×7 dots, every unlit dot left on the board. The row carries them as its label, “London: 14:05”.
6. **Wordmark**: The board’s maker line, small and semibold in the ink colour.
7. **City button**: Shows the Watch city and opens the searchable list of nine time zones.

## When to use

- A glanceable world clock for a distributed team: a few cities’ local times on one board, at their real offsets, daylight saving included.
- A dashboard or desktop-like widget where the clock is the whole job and has room to be large: about 400 px wide or more.
- A personal focus timer that lives next to the clock, with the same face and colours.
- Ambient, decorative time on a landing page or a wall screen, where the dot matrix is part of the mood.

## When not to use

- Picking a time or converting a meeting slot between zones. Use a date-time picker with a zone list or a timeline.
- A time inside a sentence or a table cell. Format it as text with `Intl.DateTimeFormat`.
- More than five cities at once: the board holds five rows. Use a list or a table of zones.
- Exact timing, like seconds or a countdown to a launch: the board shows hours and minutes only.
- Narrow sidebars and phones where it would be the size of a chip: the dots stop reading below about 300 px.

## Installation

Install it with the shadcn CLI, into components/airport-matrix of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/airport-matrix.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { AirportMatrix } from './airport-matrix';

export function WorldClock() {
  return <AirportMatrix defaultMode="World time" autoplay={false} />;
}
```

## States

- **World time**: The default: five cities, their real local times. The time is live, so it ticks here too.
- **Watch**: One large time for the city on the city button.
- **Focus, ready**: A 25-minute session waiting for Start focus.
- **Focus, running**: Counting down from a deadline, so it keeps going here; Pause holds it.
- **Focus, paused**: Time left is kept, and the button reads Resume focus.
- **Digital face**: The same times set in a monospaced face instead of dots.
- **Preferences open**: The panel floats over the board: face, colour and focus duration.
- **City list open**: Search narrows the nine zones as you type; the current city carries a check.
- **No city found**: A search that matches nothing says so instead of showing an empty list.
- **Picked colour**: A colour picked in the preferences (here Lime) holds whatever the page’s light or dark.
- **Expanded**: A taller board with more room around the rows.

## How it works

- A 5 × 7 glyph map paints each letter and digit as SVG dots. Unlit dots stay on the board and lit dots cross-fade when a minute changes.
- Intl.DateTimeFormat reads each IANA time zone, including daylight-saving changes; it does not assume fixed UTC offsets. Updates pause when the component is offscreen or its tab is hidden.
- Watch, Focus and World time share a persistent shell. The preferences panel changes its face and five colour themes, while the city picker filters nine time zones.
- The focus timer derives its remaining seconds from a deadline, so hiding the browser tab does not add time to a session. Pause preserves the remaining time; reset restores 25 minutes.
- The optional demonstration alternates the two neutral themes, Silver and Night, starting from the one that matches the page. Any pointer or keyboard input hands the demo to the reader until the preview is restarted.
- Untouched, the board’s colours are light-dark() tokens, Silver by day and Night at night, so it follows the page’s color-scheme, or the colorScheme prop. A colour picked in the preferences holds for the board, its panel and its city picker, whatever the page does.

## API reference

- **defaultMode** (`'Watch' | 'Focus' | 'World time'`, default `'World time'`): The view shown when the component mounts.
- **defaultState** (`AirportMatrixState`): Opens the clock in a given state: theme, face, city (an index in CITIES), open ('preferences' or 'cities'), query (the city search), expanded, and remaining (seconds on the focus timer) with running. The time itself stays live. Setting it turns the demo off.
- **onModeChange** (`(mode: 'Watch' | 'Focus' | 'World time') => void`): Called with the view picked on the mode switch.
- **onCityChange** (`(city: string) => void`): Called with the name of the city picked in the list.
- **onThemeChange** (`(theme: Theme) => void`): Called with the colour picked in the preferences: Silver, Night, Lime, Sand or Blue.
- **onFaceChange** (`(face: 'FIDG' | 'Digital') => void`): Called with the face picked in the preferences.
- **autoplay** (`boolean`, default `true`): Demonstrate the neutral colour themes while visible and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): An additional class for the clock shell.

## Accessibility

- The clock views are tabs with arrow, Home and End navigation. Every world-time row has a spoken city and time; the decorative SVG dots are hidden from assistive technology.
- The search field, colour buttons and focus controls are labelled. Escape closes either popover and returns focus to its trigger.
- Reduced motion removes theme, dot and popover transitions and stops the automatic demonstration. A focus completion is announced without announcing every second.

## Source

### airport-matrix/airport-matrix.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
import { Icon } from './icons';
import { useDemo } from './use-demo';
import { glyph } from './font';
import styles from './airport-matrix.module.css';

export const CITIES = [
  { name: 'Los Angeles', zone: 'America/Los_Angeles' },
  { name: 'New York', zone: 'America/New_York' },
  { name: 'London', zone: 'Europe/London' },
  { name: 'Dubai', zone: 'Asia/Dubai' },
  { name: 'Tokyo', zone: 'Asia/Tokyo' },
  { name: 'São Paulo', zone: 'America/Sao_Paulo' },
  { name: 'Paris', zone: 'Europe/Paris' },
  { name: 'Singapore', zone: 'Asia/Singapore' },
  { name: 'Sydney', zone: 'Australia/Sydney' },
];
export type Mode = 'Watch' | 'Focus' | 'World time';
const THEMES = ['Silver', 'Night', 'Lime', 'Sand', 'Blue'] as const;
export type Theme = (typeof THEMES)[number];
export type Face = 'FIDG' | 'Digital';

/**
 * Where the clock opens (defaultState), for documentation and tests: a colour, a face, a city, a
 * panel open, the board expanded, the focus timer at a given reading. Giving it turns the demo
 * off. The time itself is always the live one.
 */
export type AirportMatrixState = {
  theme?: Theme;
  face?: Face;
  /** An index in CITIES. */
  city?: number;
  open?: 'preferences' | 'cities';
  /** The city search, with the city list open. */
  query?: string;
  expanded?: boolean;
  /** Seconds left on the focus timer (25 minutes when left out). */
  remaining?: number;
  running?: boolean;
};
type Scheme = 'light' | 'dark';

/**
 * The colour scheme `root` is drawn in: the CSS gives it an unseen colour, white in light and
 * black in dark, read once mounted and again whenever the scheme may have changed: an attribute
 * of an ancestor (where pages keep their theme), the system's setting, or `colorScheme`. null
 * until read: the server cannot know it.
 */
function useScheme(root: RefObject<HTMLElement | null>, colorScheme?: string) {
  const [scheme, setScheme] = useState<Scheme | null>(null);
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const read = () =>
      setScheme(getComputedStyle(element).outlineColor === 'rgb(0, 0, 0)' ? 'dark' : 'light');
    read();
    const ancestors = new MutationObserver(read);
    for (let node = element.parentElement; node; node = node.parentElement) {
      ancestors.observe(node, { attributes: true });
    }
    const system = window.matchMedia('(prefers-color-scheme: dark)');
    system.addEventListener('change', read);
    return () => {
      ancestors.disconnect();
      system.removeEventListener('change', read);
    };
  }, [root, colorScheme]);
  return scheme;
}

function Matrix({ text, columns = 22 }: { text: string; columns?: number }) {
  const chars = text
    .toUpperCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .padEnd(columns)
    .slice(0, columns);
  return (
    <svg
      className={styles.matrix}
      data-part="matrix"
      viewBox={`0 0 ${columns * 6} 7`}
      aria-hidden="true"
    >
      {[...chars].flatMap((char, c) =>
        glyph(char).flatMap((line, y) =>
          [...line].map((dot, x) => (
            <circle
              key={`${c}-${y}-${x}`}
              cx={c * 6 + x + 0.5}
              cy={y + 0.5}
              r=".34"
              data-on={dot === '1' || undefined}
              style={{ transitionDelay: `${((c + y) % 5) * 12}ms` }}
            />
          )),
        ),
      )}
    </svg>
  );
}

export function AirportMatrix({
  defaultMode = 'World time',
  autoplay = true,
  colorScheme,
  className = '',
  defaultState,
  onModeChange,
  onCityChange,
  onThemeChange,
  onFaceChange,
}: {
  defaultMode?: Mode;
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
  /** Opens the clock in a given state (and turns the demo off). */
  defaultState?: AirportMatrixState;
  onModeChange?: (mode: Mode) => void;
  /** A city picked in the list. */
  onCityChange?: (city: string) => void;
  /** A colour picked in the preferences. */
  onThemeChange?: (theme: Theme) => void;
  onFaceChange?: (face: Face) => void;
}) {
  const { root, visible, reduced, playing, take } = useDemo<HTMLDivElement>();
  const [now, setNow] = useState<number | null>(null);
  const [mode, setMode] = useState<Mode>(defaultMode);
  /** The colour picked in the preferences; until then, the colour scheme's: Silver or Night. */
  const [picked, setPicked] = useState<Theme | undefined>(defaultState?.theme);
  /** The demo's turn at the other colour, for the scheme it was shown on. */
  const [demo, setDemo] = useState<{ theme: Theme; scheme: Scheme } | null>(null);
  const scheme = useScheme(root, colorScheme);
  /** A colour of its own (data-theme), or none: the board follows its colour scheme. */
  const chosen = picked ?? (demo?.scheme === scheme ? demo.theme : undefined);
  /** The colour the swatches show: its own, or its scheme's (light until it is read). */
  const theme = chosen ?? ((scheme ?? colorScheme) === 'dark' ? 'Night' : 'Silver');
  const [face, setFace] = useState<Face>(defaultState?.face ?? 'FIDG');
  const [prefs, setPrefs] = useState(defaultState?.open === 'preferences');
  const [picker, setPicker] = useState(defaultState?.open === 'cities');
  /** The search takes focus only when the list is opened by hand, not by defaultState. */
  const [pickerFocus, setPickerFocus] = useState(false);
  const [city, setCity] = useState(defaultState?.city ?? 1);
  const [query, setQuery] = useState(defaultState?.query ?? '');
  const [remaining, setRemaining] = useState(defaultState?.remaining ?? 25 * 60);
  const [running, setRunning] = useState(defaultState?.running ?? false);
  const [expanded, setExpanded] = useState(defaultState?.expanded ?? false);
  const [message, setMessage] = useState('');
  const settings = useRef<HTMLButtonElement>(null);
  const cityButton = useRef<HTMLButtonElement>(null);
  const format = (index: number) =>
    now === null
      ? '--:--'
      : new Intl.DateTimeFormat('en-GB', {
          timeZone: CITIES[index].zone,
          hour: '2-digit',
          minute: '2-digit',
          hourCycle: 'h23',
        }).format(now);
  const focusTime = `${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(remaining % 60).padStart(2, '0')}`;
  useEffect(() => {
    if (!visible) return;
    const update = () => setNow(Date.now());
    update();
    const timer = setInterval(update, 1000);
    return () => clearInterval(timer);
  }, [visible]);
  // A deadline keeps the timer accurate even while the tab is hidden.
  useEffect(() => {
    if (!running) return;
    const end = Date.now() + remaining * 1000;
    const tick = () => {
      const left = Math.max(0, Math.ceil((end - Date.now()) / 1000));
      setRemaining(left);
      if (!left) {
        setRunning(false);
        setMessage('Focus session complete.');
      }
    };
    const timer = setInterval(tick, 1000);
    document.addEventListener('visibilitychange', tick);
    return () => {
      clearInterval(timer);
      document.removeEventListener('visibilitychange', tick);
    };
  }, [running]); // remaining is captured when Start or Resume is pressed.
  // The demo shows the other colour (Night on a light scheme, Silver on a dark one), then the
  // scheme's own again, every 5.5 s. A new scheme (the page's theme changed) sets it aside.
  useEffect(() => {
    if (!autoplay || defaultState || !playing || !scheme) return;
    let step = 0;
    const timer = setInterval(() => {
      step++;
      setDemo(step % 2 ? { theme: scheme === 'dark' ? 'Silver' : 'Night', scheme } : null);
    }, 5500);
    return () => clearInterval(timer);
    // defaultState is read once, as the clock opens.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoplay, playing, scheme]);
  const close = () => {
    if (prefs) {
      setPrefs(false);
      settings.current?.focus();
    }
    if (picker) {
      setPicker(false);
      cityButton.current?.focus();
    }
  };
  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-airport-matrix
      data-theme={chosen}
      data-color-scheme={colorScheme}
      data-expanded={expanded || undefined}
      data-face={face}
      data-reduced={reduced || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onKeyDown={(e) => {
        if (e.key === 'Escape') {
          close();
          setExpanded(false);
        }
      }}
    >
      <header className={styles.top} data-part="header">
        <div className={styles.tabs} role="tablist" aria-label="Clock mode" data-part="tabs">
          {(['Watch', 'Focus', 'World time'] as Mode[]).map((item, i) => (
            <button
              key={item}
              type="button"
              role="tab"
              aria-selected={mode === item}
              tabIndex={mode === item ? 0 : -1}
              onClick={() => {
                setMode(item);
                onModeChange?.(item);
                close();
              }}
              onKeyDown={(e) => {
                if (['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(e.key)) {
                  e.preventDefault();
                  const n =
                    e.key === 'Home'
                      ? 0
                      : e.key === 'End'
                        ? 2
                        : (i + (e.key === 'ArrowRight' ? 1 : 2)) % 3;
                  setMode((['Watch', 'Focus', 'World time'] as Mode[])[n]);
                  onModeChange?.((['Watch', 'Focus', 'World time'] as Mode[])[n]);
                  (e.currentTarget.parentElement?.children[n] as HTMLElement)?.focus();
                }
              }}
            >
              {item}
            </button>
          ))}
        </div>
        <button
          ref={settings}
          className={styles.iconButton}
          data-part="settings"
          aria-label="Clock preferences"
          aria-expanded={prefs}
          type="button"
          onClick={() => {
            setPrefs(!prefs);
            setPicker(false);
          }}
        >
          <Icon name="settings" />
        </button>
        <button
          className={`${styles.iconButton} ${styles.expand}`}
          data-part="expand"
          aria-label={expanded ? 'Reduce clock' : 'Expand clock'}
          aria-pressed={expanded}
          type="button"
          onClick={() => setExpanded(!expanded)}
        >
          <Icon name="expand" />
        </button>
      </header>
      {prefs && (
        <section
          className={styles.preferences}
          aria-label="Clock preferences"
          data-part="preferences"
        >
          <p>System preferences</p>
          <div className={styles.face} data-part="face">
            {(['Digital', 'FIDG'] as const).map((f) => (
              <button
                key={f}
                aria-pressed={face === f}
                type="button"
                onClick={() => {
                  setFace(f);
                  onFaceChange?.(f);
                }}
              >
                {f}
              </button>
            ))}
          </div>
          <div className={styles.swatches} aria-label="Clock colour" data-part="swatches">
            {THEMES.map((t) => (
              <button
                key={t}
                aria-label={`${t} clock`}
                aria-pressed={theme === t}
                data-colour={t}
                type="button"
                onClick={() => {
                  setPicked(t);
                  onThemeChange?.(t);
                }}
              />
            ))}
          </div>
          <p>Focus duration</p>
          <div className={styles.face} data-part="duration">
            {[15, 25, 50].map((n) => (
              <button
                key={n}
                type="button"
                aria-pressed={remaining === n * 60}
                onClick={() => {
                  setRunning(false);
                  setRemaining(n * 60);
                }}
              >
                {n} min
              </button>
            ))}
          </div>
        </section>
      )}
      <section
        className={styles.board}
        role="tabpanel"
        aria-label={`${mode} display`}
        data-part="board"
      >
        {mode === 'World time' ? (
          CITIES.slice(0, 5).map((c, i) => (
            <div
              key={c.zone}
              className={styles.row}
              aria-label={`${c.name}: ${format(i)}`}
              data-part="row"
            >
              <Matrix text={`${format(i)}  ${c.name}`} />
              <span className={styles.digital}>
                {format(i)} <span>{c.name}</span>
              </span>
            </div>
          ))
        ) : (
          <div className={styles.single} data-part="single">
            <Matrix text={mode === 'Watch' ? format(city) : focusTime} columns={5} />
            <span className={styles.digital}>{mode === 'Watch' ? format(city) : focusTime}</span>
            <p>
              {mode === 'Watch'
                ? CITIES[city].name
                : running
                  ? 'Make room for your best work.'
                  : 'One thing at a time.'}
            </p>
            {mode === 'Focus' && (
              <div className={styles.focusControls} data-part="focus-controls">
                <button
                  type="button"
                  onClick={() => {
                    if (!remaining) setRemaining(25 * 60);
                    setRunning(!running);
                  }}
                >
                  {running ? 'Pause' : remaining === 25 * 60 ? 'Start focus' : 'Resume focus'}
                </button>
                <button
                  type="button"
                  aria-label="Reset focus timer"
                  onClick={() => {
                    setRunning(false);
                    setRemaining(25 * 60);
                  }}
                >
                  <Icon name="replay" />
                </button>
              </div>
            )}
          </div>
        )}
      </section>
      <footer className={styles.bottom} data-part="footer">
        <span className={styles.wordmark} data-part="wordmark">
          ◌ OH · TIME
        </span>
        <button
          ref={cityButton}
          className={styles.cityButton}
          data-part="city"
          type="button"
          aria-expanded={picker}
          onClick={() => {
            setPicker(!picker);
            setPickerFocus(true);
            setPrefs(false);
          }}
        >
          <Icon name="clock" size={12} />
          {CITIES[city].name}
          <Icon name="chevron" size={12} />
        </button>
        <span>Stay in sync.</span>
      </footer>
      {picker && (
        <section
          className={styles.cityPicker}
          aria-label="Choose a time zone"
          data-part="city-picker"
        >
          <label>
            <Icon name="search" />
            <input
              autoFocus={pickerFocus}
              type="search"
              aria-label="Search cities"
              placeholder="Find a city…"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
            />
          </label>
          <div>
            {CITIES.map((c, i) => ({ c, i }))
              .filter(({ c }) => c.name.toLowerCase().includes(query.toLowerCase()))
              .map(({ c, i }) => (
                <button
                  type="button"
                  key={c.zone}
                  aria-pressed={city === i}
                  onClick={() => {
                    setCity(i);
                    onCityChange?.(c.name);
                    setPicker(false);
                    setQuery('');
                    cityButton.current?.focus();
                  }}
                >
                  <span>{c.name}</span>
                  <small>{format(i)}</small>
                  {city === i && <Icon name="check" size={12} />}
                </button>
              ))}
            {!CITIES.some((c) => c.name.toLowerCase().includes(query.toLowerCase())) && (
              <p>No cities found.</p>
            )}
          </div>
        </section>
      )}
      <span className={styles.sr} role="status">
        {message}
      </span>
    </div>
  );
}
```

### airport-matrix/airport-matrix.module.css

```css
/* Airport matrix (airport-matrix.tsx). The board's colours are the tokens below: Silver on a
   light colour scheme and Night on a dark one, as light-dark(Silver, Night) pairs, so the board
   takes the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). A colour the reader picks, or the demo shows, is data-theme: its own
   values, whatever the scheme. */

.root {
  --bg: #eee;
  --ink: #252525;
  --off: #d7d7d7;
  --surface: #e5e5e5;
  --button: #fff;
  /* For the script: a colour the root never shows (it has no outline), white in light and black
     in dark, which tells the clock the scheme the root is drawn in. */
  outline-color: #fff;
  position: relative;
  width: 100%;
  min-height: 392px;
  padding: 18px 20px 15px;
  border: 1px solid #0000000c;
  border-radius: 22px;
  background: var(--bg);
  color: var(--ink);
  font:
    12px/1.4 'Library Inter',
    Arial,
    sans-serif;
  container-type: inline-size;
  transition:
    background 0.5s,
    color 0.5s;
  isolation: isolate;
  box-sizing: border-box;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --bg: light-dark(#eee, #191919);
    --ink: light-dark(#252525, #f2f2f2);
    --off: light-dark(#d7d7d7, #333);
    --surface: light-dark(#e5e5e5, #303030);
    --button: light-dark(#fff, #414141);
    outline-color: light-dark(#fff, #000);
  }
}
.root * {
  box-sizing: border-box;
}
.root button {
  color: inherit;
  font: inherit;
  border: 0;
  cursor: pointer;
}
.root button:focus-visible,
.root input:focus-visible {
  outline: 2px solid #4788f5;
  outline-offset: 3px;
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
/* A picked colour also sets the scheme of what is inside (the search field, the list's
   scrollbar): Night's is dark, the others' light. The root keeps the page's, which the script
   reads. */
.root[data-theme] > * {
  color-scheme: light;
}
.root[data-theme='Night'] > * {
  color-scheme: dark;
}
.root[data-theme='Silver'] {
  --bg: #eee;
  --ink: #252525;
  --off: #d7d7d7;
  --surface: #e5e5e5;
  --button: #fff;
}
.root[data-theme='Night'] {
  --bg: #191919;
  --ink: #f2f2f2;
  --off: #333;
  --surface: #303030;
  --button: #414141;
}
.root[data-theme='Lime'] {
  --bg: #e4ecc3;
  --ink: #3d4922;
  --off: #c8d2a7;
  --surface: #d6e0b5;
  --button: #eef4d7;
}
.root[data-theme='Sand'] {
  --bg: #f1e5d4;
  --ink: #66462b;
  --off: #dfd0bc;
  --surface: #e9dac6;
  --button: #fff4e5;
}
.root[data-theme='Blue'] {
  --bg: #d8e5f5;
  --ink: #2d4a70;
  --off: #b9cce4;
  --surface: #c9d9ec;
  --button: #edf4ff;
}
.top {
  display: flex;
  align-items: center;
  gap: 9px;
}
.tabs,
.face {
  display: flex;
  align-items: center;
  padding: 3px;
  background: var(--surface);
  border-radius: 30px;
  gap: 1px;
}
.tabs button,
.face button {
  border-radius: 20px;
  padding: 5px 9px;
  background: transparent;
  font-size: 10px;
  white-space: nowrap;
  transition:
    background 0.2s,
    box-shadow 0.2s;
}
.tabs button[aria-selected='true'],
.face button[aria-pressed='true'] {
  background: var(--button);
  box-shadow: 0 1px 3px #00000014;
}
.iconButton {
  width: 30px;
  height: 26px;
  display: grid;
  place-items: center;
  background: var(--surface);
  border-radius: 20px;
}
.expand {
  margin-left: auto;
}
.board {
  padding: 52px 9% 45px;
  min-height: 290px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 15px;
}
.root .matrix {
  width: 100%;
  height: auto;
  display: block;
  overflow: visible;
}
.matrix circle {
  fill: var(--off);
  transition:
    fill 0.2s,
    opacity 0.2s;
}
.matrix circle[data-on] {
  fill: var(--ink);
}
.digital {
  display: none;
  font:
    400 24px/1.2 'Library Mono',
    monospace;
  white-space: nowrap;
  text-transform: uppercase;
}
.digital > span {
  margin-left: 12px;
  font-size: 19px;
}
.root[data-face='Digital'] .matrix {
  display: none;
}
.root[data-face='Digital'] .digital {
  display: block;
}
.single {
  text-align: center;
}
.single .matrix {
  max-width: 340px;
  margin: auto;
}
.single .digital {
  font-size: 70px;
}
.single p {
  margin: 20px 0 0;
  opacity: 0.6;
  font-size: 11px;
}
.focusControls {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 18px;
}
.focusControls button {
  background: var(--button);
  border-radius: 30px;
  min-height: 30px;
  padding: 5px 15px;
  display: flex;
  align-items: center;
}
.bottom {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  font-size: 9px;
  opacity: 0.8;
}
.wordmark {
  font-weight: 600;
  letter-spacing: 0.04em;
}
.bottom > span:last-child {
  font-style: italic;
}
.cityButton {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 7px 12px;
  background: var(--button);
  border-radius: 25px;
  box-shadow: 0 1px 7px #00000008;
}
.preferences {
  position: absolute;
  z-index: 4;
  top: 50px;
  left: 18px;
  padding: 15px;
  width: 202px;
  background: var(--surface);
  border: 1px solid #80808018;
  border-radius: 16px;
  box-shadow: 0 12px 32px #00000012;
  animation: arrive 0.2s ease-out;
}
.preferences p {
  font-size: 8px;
  opacity: 0.6;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin: 0 0 8px;
}
.preferences .face {
  justify-content: flex-start;
  padding: 0;
}
.preferences p:not(:first-child) {
  margin-top: 18px;
}
.swatches {
  display: flex;
  gap: 8px;
  margin: 14px 0;
}
.swatches button {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: #eee;
  border: 2px solid #ffffff80;
  outline: 1px solid #8883;
}
.swatches [data-colour='Night'] {
  background: #222;
}
.swatches [data-colour='Lime'] {
  background: #e5ecaa;
}
.swatches [data-colour='Sand'] {
  background: #e7d2b2;
}
.swatches [data-colour='Blue'] {
  background: #b6d6fa;
}
.swatches button[aria-pressed='true'] {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
.cityPicker {
  position: absolute;
  z-index: 5;
  bottom: 50px;
  left: 50%;
  transform: translateX(-50%);
  width: min(250px, 85%);
  padding: 8px;
  background: var(--surface);
  border: 1px solid #80808018;
  border-radius: 16px;
  box-shadow: 0 12px 32px #0002;
  animation: arrive 0.2s ease-out;
}
.cityPicker label {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 5px 8px;
  background: var(--button);
  border-radius: 20px;
}
.cityPicker input {
  width: 100%;
  min-width: 0;
  font: inherit;
  font-size: 11px;
  background: transparent;
  color: inherit;
  border: 0;
  outline: 0;
  padding: 5px 0;
}
.cityPicker > div {
  max-height: 175px;
  overflow: auto;
  margin-top: 5px;
}
.cityPicker button {
  display: flex;
  align-items: center;
  width: 100%;
  gap: 8px;
  background: transparent;
  padding: 8px;
  border-radius: 10px;
  text-align: left;
  font-size: 11px;
}
.cityPicker button:hover,
.cityPicker button[aria-pressed='true'] {
  background: var(--button);
}
.cityPicker small {
  margin-left: auto;
  opacity: 0.6;
}
.cityPicker p {
  padding: 10px;
  font-size: 11px;
  text-align: center;
}
.root[data-expanded] {
  min-height: 500px;
}
.root[data-expanded] .board {
  padding-block: 85px;
}
.sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
@keyframes arrive {
  from {
    opacity: 0;
    translate: 0 5px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}
@container (max-width:400px) {
  .board {
    padding: 45px 0;
    gap: 17px;
  }
  .tabs button {
    font-size: 9px;
    padding: 5px 7px;
    min-height: 32px;
  }
  .iconButton {
    width: 36px;
    height: 36px;
  }
  .swatches button {
    width: 32px;
    height: 32px;
  }
  .face button,
  .cityButton,
  .cityPicker button {
    min-height: 36px;
  }
  .bottom > span:last-child {
    display: none;
  }
  .bottom {
    font-size: 8px;
  }
  .digital {
    font-size: 21px;
  }
  .digital > span {
    font-size: 14px;
    margin-left: 7px;
  }
  .single .digital {
    font-size: 60px;
  }
}
@media (prefers-reduced-motion: reduce) {
  .root,
  .root * {
    animation: none !important;
    transition: none !important;
  }
}
```

### airport-matrix/font.ts

```tsx
/** A 5 × 7 dot face: every unlit cell remains visible, like an airport board. */
const GLYPHS: Record<string, string[]> = {
  '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
  '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
  '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
  '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
  '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
  '5': ['11111', '10000', '10000', '11110', '00001', '00001', '11110'],
  '6': ['01110', '10000', '10000', '11110', '10001', '10001', '01110'],
  '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
  '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
  '9': ['01110', '10001', '10001', '01111', '00001', '00001', '01110'],
  ':': ['00000', '00100', '00100', '00000', '00100', '00100', '00000'],
  A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
  B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
  C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'],
  D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
  E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
  F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
  G: ['01111', '10000', '10000', '10111', '10001', '10001', '01111'],
  H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
  I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'],
  J: ['00001', '00001', '00001', '00001', '10001', '10001', '01110'],
  K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
  L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
  M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
  N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
  O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
  P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
  Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
  R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
  S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
  T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
  U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
  V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
  W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
  X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
  Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
  Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
};
export function glyph(char: string) {
  return GLYPHS[char] ?? (Array(7).fill('00000') as string[]);
}
```

### airport-matrix/use-demo.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/** A visible demo may play until someone takes over. Restarting the preview resets it. */
export function useDemo<T extends HTMLElement>() {
  const root = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [manual, setManual] = useState(false);
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let intersecting = false;
    const update = () => setVisible(intersecting && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        intersecting = entry.isIntersecting;
        update();
      },
      { threshold: 0.15 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const media = window.matchMedia('(prefers-reduced-motion: reduce)');
    const motion = () => setReduced(media.matches);
    motion();
    media.addEventListener('change', motion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      media.removeEventListener('change', motion);
    };
  }, []);
  const take = useCallback(() => setManual(true), []);
  return { root, visible, reduced, take, playing: visible && !reduced && !manual };
}
```

### airport-matrix/icons.tsx

```tsx
/** The small line icons this component draws, on a 24-unit grid in the current colour. */
export function Icon({ name, size = 16 }: { name: string; size?: number }) {
  const paths: Record<string, React.ReactNode> = {
    settings: (
      <>
        <path d="M12 2v4M12 18v4M2 12h4M18 12h4M5 5l3 3M16 16l3 3M5 19l3-3M16 8l3-3" />
        <circle cx="12" cy="12" r="6" />
        <circle cx="12" cy="12" r="2" />
      </>
    ),
    expand: <path d="M9 3H3v6M15 21h6v-6M3 3l7 7M21 21l-7-7" />,
    search: (
      <>
        <circle cx="10" cy="10" r="6" />
        <path d="m15 15 6 6" />
      </>
    ),
    check: <path d="m5 12 4 4L19 6" />,
    chevron: <path d="m8 10 4 4 4-4" />,
    replay: (
      <>
        <path d="M4 10a8 8 0 1 1 1 7M4 4v6h6" />
      </>
    ),
    clock: (
      <>
        <circle cx="12" cy="12" r="9" />
        <path d="M12 6v6l4 2" />
      </>
    ),
  };
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      style={{ width: size, height: size, flexShrink: 0 }}
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {paths[name]}
    </svg>
  );
}
```