Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Airport matrix

New

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

airport-matrix/airport-matrix.tsx
Live · interactive
--:-- Los Angeles
--:-- New York
--:-- London
--:-- Dubai
--:-- Tokyo
◌ OH · TIMEStay in sync.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/airport-matrix of any React project, with no setup:

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

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

world-clock.tsx
import { AirportMatrix } from './airport-matrix';export function WorldClock() {  return <AirportMatrix defaultMode="World time" autoplay={false} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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 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.
PropTypeDefaultDescription
defaultMode'Watch' | 'Focus' | 'World time''World time'The view shown when the component mounts.
defaultStateAirportMatrixState—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.
autoplaybooleantrueDemonstrate 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.
classNamestring—An additional class for the clock shell.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

airport-matrix.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];
airport-matrix.module.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:
font.ts
/** 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'],
use-demo.ts
'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);
icons.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" />      </>    ),
  • 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.