# Train footer

> A site footer over an engraved brick viaduct, which a little steam train crosses in a plume of smoke.

A footer of brand, copy and three link columns on white paper, with a wide sky and an engraved sepia viaduct along the bottom. Every so often a goods train crosses it from left to right, gathering pace, its wheels turning and a billowing trail of smoke swelling and fading behind it. On first sight the top rule draws itself and the words rise in one after another.

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

Category: Motion.

## Anatomy

1. **Top rule**: A 1 px hairline across the full width; on first sight it draws itself from the left over 700 ms.
2. **Brand**: The wheel mark and the `brand` name in serif, a link home. The mark turns 45° on hover.
3. **Tagline**: The `tagline` under the brand, at most 24 em wide, with the `copyright` small print below it.
4. **Link columns**: A `<nav aria-label="Footer">` of `columns`: a serif heading each, then its links, which underline on hover.
5. **Scene**: The sky and the viaduct, decorative and `aria-hidden`. Full size from 1440 px wide, never under half; a click pauses the train.
6. **Train**: Two goods wagons, a tender and an engine, 856 × 180 units. It gathers pace as it crosses, its wheels turning by the distance run.
7. **Smoke**: Puffs from a fixed pool, one leaving the chimney every 200 ms: carried a little, rising, swelling, then fading.
8. **Viaduct**: One engraved brick bay, pier to pier, repeated with `<use>` as far as the footer is wide.

## When to use

- The last screen of a brand or marketing site that has room for a closing picture under its links.
- A product whose story is a journey (routes, releases, stops along a line), so the train says it without words.
- A footer people reach on purpose and linger on: the scene rewards the scroll, the links still come first.
- A site with an illustrated or editorial voice, where an engraved drawing sits well with serif headings.

## When not to use

- An app or dashboard shell, where the footer is a thin bar of legal links. Use a plain one-line footer.
- More than four link columns or a long sitemap. Use a dense multi-column footer with no scene.
- Pages that must stay light and quiet (checkout, docs, settings). Use a plain footer; the scene is about 870 px tall at full size.
- A brand with nothing to do with travel or steady progress. Draw a scene of its own, or use a plain footer with the brand mark.
- Content that must be read in the picture. The scene is decorative and hidden from screen readers; put words in the columns.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/train-footer.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 { TrainFooter } from './train-footer';

export function SiteFooter() {
  return (
    <TrainFooter
      brand="waymark"
      tagline="The steady line from idea to release."
      copyright="© 2026 Waymark Labs Ltd."
      columns={[
        { title: 'Explore', links: [{ label: 'Product', href: '/product' }] },
        { title: 'Depot', links: [{ label: 'Contact', href: '/contact' }] },
      ]}
    />
  );
}
```

## States

- **Empty viaduct**: `still={0}`: between crossings, the sky clear and the viaduct bare.
- **Arriving**: `still={0.25}`: the engine has just come in from the left, its plume starting behind it.
- **Halfway**: `still={0.5}`: the whole train on the deck under a long plume. Under reduced motion the footer draws a frame like this one on its own.
- **Leaving**: `still={0.9}`: the engine gone off the right edge, the last wagons and the smoke still in sight.
- **Light**: `colorScheme="light"`: sepia ink on white paper.
- **Dark**: `colorScheme="dark"`: near-black paper, the engraving in pale sepia lines.
- **Own words**: Another `brand`, `tagline`, `copyright` and two `columns`; the grid keeps the room of three.
- **Narrow**: Under 860 px the words stack above the columns and step down a size; the scene scales to 57%.
- **Phone**: At 375 px the scene stops shrinking at half size, so the arches and the train keep their shape.

## How it works

- The engraving is SVG in one unit system (1 unit = 1 px at full size). Shading is engraved rather than flat: the vaults are a dark mass pricked with light dots, faces carry brick or hatch lines, the train is mid-tone plate with ink seams. Every pattern is in user space, so it stays crisp at any scale, and every paint is a CSS token.
- The viaduct is one bay, pier to pier, drawn once and repeated with <use> as far as the footer is wide. Its arches rise a little higher than a semicircle, with a ring of voussoirs, a keystone and daylight seen through the far side of each vault.
- The footer measures its own width and scales the scene to it: full size from 1440 px, never under half, so on a phone the train and the arches keep their proportions and the words stack above them.
- One clock drives the scene, and the train’s place is a pure function of time: it crosses at 295 px a second on average, starting at 82% of that and ending about 45% faster than it started. Its wheels and coupling rod turn by the distance travelled, through a CSS variable and cos()/sin().
- The smoke is a stateless particle system. A puff leaves the chimney every 200 ms of the crossing; where it is at any moment follows from when it left (the chimney’s place then) and its age: carried forward a little, rising, swelling from under half its size to about one and a half, and fading over the last 28% of its life. Each puff is one of three cumulus shapes, its lobes stroked and then filled over so only the outline and the engraved arcs inside remain.
- Frames write transforms and opacity straight to a fixed pool of elements, with no React render; the loop runs only while the footer is on screen and the tab is visible, and stops when paused. Between crossings the viaduct stays empty while the last smoke clears.
- The reveal plays once, the first time the footer is seen: the top rule scales in from the left over 700 ms, and each word block rises 8 px and fades in, 55 ms after the one before.

## API reference

- **brand** (`string`, default `'waymark'`): The name beside the wheel mark.
- **tagline** (`string`): The line of copy under the brand.
- **copyright** (`string`): The small print under the tagline.
- **columns** (`{ title: string; links: { label: string; href: string }[] }[]`): The link columns, each a heading and its links. Three fit best.
- **speed** (`number`, default `295`): The train’s average speed across the viaduct, in px a second at full size.
- **autoplay** (`boolean`, default `true`): Run the train on a loop while the footer is on screen; false, it crosses once each time the footer comes into view.
- **still** (`number`): Freezes the scene this far through a crossing (0 to 1, by time), with no loop, reveal or pause; 0 is the empty viaduct. Leave it out for the live footer.
- **colorScheme** (`'light' | 'dark' | 'system'`, default `'system'`): Light or dark, or the page’s own color-scheme.
- **className** (`string`): Classes for the footer element.

## Accessibility

- A real <footer> with a <nav aria-label="Footer">: each column is a heading and a list of links, in reading order.
- The whole scene is decorative and aria-hidden; nothing in it is focusable.
- Moving content can be stopped: a “Pause the train” toggle (aria-pressed) appears when it gets keyboard focus, and a click on the scene does the same.
- Links and the brand show a focus ring with :focus-visible; links underline on hover.
- Under reduced motion there is no reveal and the train stands still halfway across, its plume in place.
- The animation stops while the footer is off screen or the tab is hidden.

## Source

### train-footer/train-footer.tsx

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

'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import {
  BAY,
  CHIMNEY,
  PUFF_HEIGHT,
  PUFF_KINDS,
  PUFF_WIDTH,
  PuffSymbols,
  TRAIN_HEIGHT,
  TRAIN_WIDTH,
  Train,
  Viaduct,
} from './art';
import { useAutoplay } from './use-autoplay';
import styles from './train-footer.module.css';

/*
 * Train footer: a site footer (brand, a line of copy, three columns of links) above a wide sky and
 * an engraved brick viaduct. Every few seconds a little steam train crosses the viaduct from left
 * to right, trailing a plume of smoke.
 *
 * - The drawing is SVG in its own units (1 unit = 1 px at full size). The footer measures its
 *   width and scales the scene to it (full size from 1440 px wide, never under half), so the train
 *   and viaduct keep their proportions on a phone.
 * - One clock drives it all. The train's place is a pure function of the time: it crosses at
 *   `speed` units a second on average, gathering a little pace as it goes (about 45% faster at the
 *   end than at the start), and the wheels and coupling rod turn by the distance travelled.
 * - The smoke is a stateless particle system: a puff leaves the chimney every 200 ms of the run,
 *   and where each one is at time t follows from when it left (the train's place then) and its age
 *   — it is carried forward a little, rises, swells and, in the last 45% of its life, fades. So the
 *   same function draws a still frame for reduced motion, and the puffs are recycled from a fixed
 *   pool of elements moved only by transform and opacity.
 * - Each frame writes those transforms straight to the elements (no React render); the loop runs
 *   only while the footer is on screen, the tab is visible and nobody has paused it.
 * - On first sight the footer draws its top rule from left to right and its words rise in, one
 *   after another.
 * - Reduced motion: no reveal, and the train stands still halfway across with its plume.
 * - Light or dark: every colour is a light-dark() token in the CSS; by night the engraving turns
 *   to pale sepia lines on near-black.
 */

export type TrainFooterLink = { label: string; href: string };
export type TrainFooterColumn = { title: string; links: TrainFooterLink[] };

const COLUMNS: TrainFooterColumn[] = [
  {
    title: 'Explore',
    links: [
      { label: 'Product', href: '#' },
      { label: 'Roadmaps', href: '#' },
      { label: 'Connectors', href: '#' },
    ],
  },
  {
    title: 'Routes',
    links: [
      { label: 'For Teams', href: '#' },
      { label: 'For Studios', href: '#' },
      { label: 'For Agencies', href: '#' },
    ],
  },
  {
    title: 'Depot',
    links: [
      { label: 'About', href: '#' },
      { label: 'Journal', href: '#' },
      { label: 'Contact', href: '#' },
    ],
  },
];

/** The scene at full size from this width, and never smaller than half. */
const FULL_WIDTH = 1440;
const MIN_SCALE = 0.5;
/** The sky above the deck, in units: room for the train and its plume. */
const SKY = 468;
/** Where the viaduct's first pier stands, in units, so a bay never starts on the edge. */
const VIADUCT_START = -225;
/** The train's speed at the start, as a share of its average: it ends at 2 − A of it. */
const A = 0.82;
/** A puff every DT seconds while the train runs; each lives about LIFE seconds. */
const DT = 0.2;
const LIFE = 3.2;
const POOL = Math.ceil((LIFE * 1.35) / DT) + 2;
/** Between two crossings, the viaduct stays empty this long once the smoke has gone. */
const REST = 1.2;

/** A repeatable random number in [0, 1) for puff `k`, draw `n`. */
function random(k: number, n: number) {
  const x = Math.sin(k * 127.1 + n * 311.7) * 43758.5453;
  return x - Math.floor(x);
}

type Geometry = { width: number; scale: number; units: number };

/** The crossing for a scene `units` wide: its length, in units, and its duration in seconds. */
function crossing(units: number, speed: number) {
  const distance = units + TRAIN_WIDTH + 40;
  return { distance, duration: distance / speed };
}

/** The train's left edge at `time` seconds into a crossing. */
function trainX(time: number, units: number, speed: number) {
  const { distance, duration } = crossing(units, speed);
  const u = Math.min(1, Math.max(0, time / duration));
  return -TRAIN_WIDTH - 20 + distance * u * (A + (1 - A) * u);
}

type Puff = { x: number; y: number; scale: number; opacity: number; kind: number; flip: boolean };

/** Puff `k` at `time` seconds into a crossing, or null when it is not in the air. */
function puff(k: number, time: number, units: number, speed: number): Puff | null {
  const born = k * DT;
  const { duration } = crossing(units, speed);
  if (born > duration) return null;
  const wisp = k % 13 === 7;
  const life = LIFE * (0.85 + 0.3 * random(k, 3)) * (wisp ? 1.3 : 1);
  const age = time - born;
  if (age < 0 || age > life) return null;
  const x0 = trainX(born, units, speed) + CHIMNEY.x;
  if (x0 < -260 || x0 > units + 80) return null;
  const size = (0.8 + 0.4 * random(k, 1)) * (wisp ? 0.35 : 1);
  const rise = (0.8 + 0.4 * random(k, 4)) * (wisp ? 1.6 : 1);
  const fadeFrom = life * 0.72;
  return {
    x: x0 + 34 * (1 - Math.exp(-age / 0.5)) - 10 * age + (random(k, 5) - 0.5) * 10,
    y: CHIMNEY.y - TRAIN_HEIGHT - 10 - rise * (36 * (1 - Math.exp(-age / 0.7)) + 8 * age),
    scale: size * (0.45 + 0.95 * (1 - Math.exp(-age / 0.8))),
    opacity:
      Math.min(1, age / 0.08) * (age > fadeFrom ? 1 - (age - fadeFrom) / (life - fadeFrom) : 1),
    kind: Math.floor(random(k, 0) * PUFF_KINDS),
    flip: random(k, 2) < 0.5,
  };
}

type Props = {
  className?: string;
  /** Run the train on a loop while the footer is on screen; false, once each time it comes into view. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  /** The name by the mark. */
  brand?: string;
  /** The line under it. */
  tagline?: string;
  /** The small print. */
  copyright?: string;
  /** The link columns, each a title and its links. */
  columns?: TrainFooterColumn[];
  /** The train's average speed across the viaduct, in px a second at full size. */
  speed?: number;
  /** Freezes the scene this far through a crossing (0 to 1, by time): no loop, no reveal, no
   * pause. 0 is the empty viaduct; leave it out for the live footer. */
  still?: number;
};

export function TrainFooter({
  className,
  autoplay = true,
  colorScheme = 'system',
  brand = 'waymark',
  tagline = 'The steady line from idea to release. Plan, route, and ship from stop to stop',
  copyright = '© 2026 Waymark Labs Ltd.',
  columns = COLUMNS,
  speed = 295,
  still,
}: Props) {
  const id = useId().replace(/:/g, '');
  const { ref, visible, reduced } = useAutoplay<HTMLElement>();
  const [geometry, setGeometry] = useState<Geometry>({ width: 1440, scale: 1, units: 1440 });
  const [paused, setPaused] = useState(false);
  const [done, setDone] = useState(false);
  const [reveal, setReveal] = useState<'static' | 'waiting' | 'shown'>('static');
  const trainRef = useRef<HTMLDivElement>(null);
  const puffRefs = useRef<(HTMLDivElement | null)[]>([]);
  const useRefs = useRef<(SVGUseElement | null)[]>([]);
  const time = useRef(0);
  const frozen = still !== undefined;

  // Measure the footer and scale the scene to it.
  useLayoutEffect(() => {
    const element = ref.current;
    if (!element) return;
    const measure = () => {
      const width = element.clientWidth;
      if (!width) return;
      const scale = Math.min(1, Math.max(MIN_SCALE, width / FULL_WIDTH));
      setGeometry((old) => (old.width === width ? old : { width, scale, units: width / scale }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [ref]);

  // The reveal: hidden once the page is live, shown the first time the footer is seen.
  useEffect(() => {
    if (reduced || frozen) setReveal('static');
    else setReveal((old) => (old === 'static' ? 'waiting' : old));
  }, [reduced, frozen]);
  useEffect(() => {
    if (visible && reveal === 'waiting') setReveal('shown');
  }, [visible, reveal]);

  // Without autoplay the train crosses once each time the footer comes into view.
  useEffect(() => {
    if (!visible && done) {
      setDone(false);
      time.current = 0;
    }
  }, [visible, done]);

  // Draws the scene at `t` seconds into a crossing.
  const speedRef = useRef(speed);
  useEffect(() => {
    speedRef.current = speed;
  }, [speed]);
  const draw = useRef((t: number, g: Geometry) => {
    const speed = speedRef.current;
    const train = trainRef.current;
    if (!train) return;
    const x = trainX(t, g.units, speed);
    train.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
    train.style.setProperty('--roll', x.toFixed(1));
    const first = Math.max(0, Math.ceil((t - LIFE * 1.35) / DT));
    const used = new Set<number>();
    for (let k = first; k <= Math.floor(t / DT); k++) {
      const p = puff(k, t, g.units, speed);
      const slot = k % POOL;
      const element = puffRefs.current[slot];
      if (!p || !element) continue;
      used.add(slot);
      if (!element.hasAttribute('data-shown')) element.setAttribute('data-shown', '');
      element.style.transform = `translate3d(${(p.x - PUFF_WIDTH / 2).toFixed(1)}px, ${(p.y - PUFF_HEIGHT / 2).toFixed(1)}px, 0) scale(${p.flip ? -p.scale : p.scale}, ${p.scale})`;
      element.style.opacity = p.opacity.toFixed(3);
      element.style.zIndex = String(k % 1000);
      const use = useRefs.current[slot];
      const href = `#${id}-puff${p.kind}`;
      if (use && use.getAttribute('href') !== href) use.setAttribute('href', href);
    }
    puffRefs.current.forEach((element, slot) => {
      if (!element || used.has(slot)) return;
      element.style.opacity = '0';
      if (element.hasAttribute('data-shown')) element.removeAttribute('data-shown');
    });
  });

  const running = visible && !reduced && !paused && !done && !frozen;

  useEffect(() => {
    const { duration } = crossing(geometry.units, speed);
    const cycle = duration + LIFE * 1.35 + REST;
    if (still !== undefined) {
      draw.current(Math.min(1, Math.max(0, still)) * duration, geometry);
      return;
    }
    if (reduced) {
      // Halfway across, the plume in full.
      let still = duration * 0.5;
      for (let i = 0; i < 40; i++) {
        const front = trainX(still, geometry.units, speed) + TRAIN_WIDTH;
        still += ((geometry.units * 0.62 - front) / speed) * 0.8;
      }
      draw.current(Math.max(LIFE, still), geometry);
      return;
    }
    draw.current(time.current, geometry);
    if (!running) return;
    let frame = 0;
    let last = performance.now();
    const tick = (now: number) => {
      time.current += Math.min(0.05, (now - last) / 1000);
      last = now;
      if (time.current >= cycle) {
        if (!autoplay) {
          time.current = cycle;
          draw.current(time.current, geometry);
          setDone(true);
          return;
        }
        time.current = 0;
      }
      draw.current(time.current, geometry);
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [running, reduced, geometry, autoplay, speed, still]);

  const bays = Math.ceil((geometry.units - VIADUCT_START) / BAY) + 1;

  return (
    <footer
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-reveal={reveal}
      style={{ '--s': geometry.scale } as CSSProperties}
    >
      <div className={styles.rule} data-part="rule" aria-hidden="true" />
      <div className={styles.content} data-part="content">
        <div className={styles.about}>
          <a
            className={styles.brand}
            href="#"
            data-part="brand"
            style={{ '--i': 0 } as CSSProperties}
          >
            <svg className={styles.mark} viewBox="0 0 30 30" aria-hidden="true">
              <circle cx="15" cy="15" r="13" />
              <circle cx="15" cy="15" r="2.4" />
              <path d="M15 2v26M2 15h26M5.8 5.8l18.4 18.4M24.2 5.8L5.8 24.2" />
            </svg>
            <span>{brand}</span>
          </a>
          <p className={styles.tagline} data-part="tagline" style={{ '--i': 1 } as CSSProperties}>
            {tagline}
          </p>
          <p
            className={styles.copyright}
            data-part="copyright"
            style={{ '--i': 2 } as CSSProperties}
          >
            {copyright}
          </p>
        </div>
        <nav className={styles.columns} aria-label="Footer" data-part="columns">
          {columns.map((column, c) => (
            <div key={column.title} className={styles.column} data-part="column">
              <h2
                className={styles.heading}
                data-part="heading"
                style={{ '--i': 3 + c } as CSSProperties}
              >
                {column.title}
              </h2>
              <ul className={styles.links}>
                {column.links.map((link, r) => (
                  <li key={link.label} style={{ '--i': 4 + c + r } as CSSProperties}>
                    <a className={styles.link} href={link.href} data-part="link">
                      {link.label}
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>
      </div>

      <div
        className={styles.scene}
        data-part="scene"
        aria-hidden="true"
        onClick={() => !reduced && !frozen && setPaused((old) => !old)}
      >
        <div className={styles.stage} style={{ top: `calc(${SKY}px * var(--s))` }}>
          <svg className={styles.defs} aria-hidden="true">
            <PuffSymbols id={id} />
          </svg>
          {Array.from({ length: POOL }, (_, slot) => (
            <div
              key={slot}
              className={styles.puff}
              data-part="puff"
              ref={(element) => {
                puffRefs.current[slot] = element;
              }}
            >
              <svg viewBox={`0 0 ${PUFF_WIDTH} ${PUFF_HEIGHT}`}>
                <use
                  ref={(element) => {
                    useRefs.current[slot] = element;
                  }}
                  href={`#${id}-puff0`}
                />
              </svg>
            </div>
          ))}
          <div className={styles.train} data-part="train" ref={trainRef}>
            <svg viewBox={`0 0 ${TRAIN_WIDTH} ${TRAIN_HEIGHT}`}>
              <Train id={`${id}t`} />
            </svg>
          </div>
        </div>
        <svg
          className={styles.viaduct}
          data-part="viaduct"
          viewBox={`0 0 ${geometry.units.toFixed(1)} 400`}
          preserveAspectRatio="xMinYMin meet"
        >
          <Viaduct id={id} count={bays} start={VIADUCT_START} />
        </svg>
      </div>

      {!reduced && !frozen && (
        <button
          type="button"
          className={styles.toggle}
          aria-pressed={paused}
          onClick={() => setPaused((old) => !old)}
        >
          {paused ? 'Play the train' : 'Pause the train'}
        </button>
      )}
    </footer>
  );
}
```

### train-footer/art.tsx

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

/*
 * The engraving: a brick viaduct, a small goods train and its smoke, all drawn in SVG in one unit
 * system (1 unit = 1 px at full size; the footer scales the lot). Every colour is a CSS token, so
 * the same drawing works by day and by night.
 *
 * - Shading is engraved: dense masses (the arch vaults, the train's dark parts) are filled with a
 *   dot or line pattern rather than a flat tone, and the light faces carry fine brick or hatch
 *   lines. Patterns are user-space, so they stay crisp at any scale.
 * - The viaduct is one bay (pier to pier) drawn once in <defs> and repeated with <use>.
 * - Puffs of smoke are three cloud shapes: their circles are stroked thick and then filled on top,
 *   which leaves only the outer outline; a few curls inside give the engraved billow.
 */

import type { CSSProperties } from 'react';
import styles from './train-footer.module.css';

/** One bay of the viaduct, pier centre to pier centre, and the height drawn below the deck. */
export const BAY = 235;
export const VIADUCT_HEIGHT = 400;
/** The train's box; its wheels stand on y = TRAIN_HEIGHT. */
export const TRAIN_WIDTH = 856;
export const TRAIN_HEIGHT = 180;
/** The chimney's mouth, in the train's box. */
export const CHIMNEY = { x: 808, y: 14 };
/** The puff drawing's box; puffs are placed by their middle. */
export const PUFF_WIDTH = 140;
export const PUFF_HEIGHT = 100;

/* ---------------------------------------------------------------- patterns */

export function Patterns({ id }: { id: string }) {
  return (
    <defs>
      {/* Brickwork: light bricks, ink mortar, a short tooling mark on each. */}
      <pattern id={`${id}-brick`} width="22" height="10" patternUnits="userSpaceOnUse">
        <rect width="22" height="10" className={styles.fillLight} />
        <path
          d="M0 0.6H22M0 5.6H22M0.6 0.6V5.6M11.6 5.6V10.6"
          className={styles.strokeInk}
          strokeWidth="1.2"
        />
        <path
          d="M3 3.1h3M8 3.1h2M14 8.1h3M19 8.1h2"
          className={styles.strokeInk}
          strokeWidth="1.1"
        />
      </pattern>
      {/* The vaults: a dark mass pricked with small light dots. */}
      <pattern id={`${id}-dots`} width="5" height="5" patternUnits="userSpaceOnUse">
        <rect width="5" height="5" className={styles.fillShade} />
        <rect x="1" y="1.4" width="2" height="1.3" className={styles.fillDot} />
      </pattern>
      {/* Fine vertical hatching: balusters, slats. */}
      <pattern id={`${id}-vhatch`} width="3" height="8" patternUnits="userSpaceOnUse">
        <rect width="3" height="8" className={styles.fillLight} />
        <path d="M0.6 0V8" className={styles.strokeInk} strokeWidth="1" />
      </pattern>
      {/* Horizontal hatching: the boiler's round, the wagons' planks. */}
      <pattern id={`${id}-hhatch`} width="8" height="4" patternUnits="userSpaceOnUse">
        <rect width="8" height="4" className={styles.fillLight} />
        <path d="M0 0.6H8" className={styles.strokeInk} strokeWidth="1.1" />
      </pattern>
      {/* Wagon planks: mid-tone boards with an ink seam and a light edge. */}
      <pattern id={`${id}-planks`} width="8" height="6" patternUnits="userSpaceOnUse">
        <rect width="8" height="6" className={styles.fillMid} />
        <path d="M0 0.8H8" className={styles.strokeInk} strokeWidth="1.6" />
        <path d="M0 2.4H8" className={styles.strokeLight} strokeWidth="0.7" />
      </pattern>
      {/* The tender's slats: mid-tone with light upright lines. */}
      <pattern id={`${id}-slats`} width="5" height="8" patternUnits="userSpaceOnUse">
        <rect width="5" height="8" className={styles.fillMid} />
        <path d="M1 0V8" className={styles.strokeLight} strokeWidth="1.3" />
        <path d="M3.4 0V8" className={styles.strokeInk} strokeWidth="1.4" />
      </pattern>
      {/* Boiler plate: mid-tone with fine horizontal engraving. */}
      <pattern id={`${id}-boiler`} width="8" height="3" patternUnits="userSpaceOnUse">
        <rect width="8" height="3" className={styles.fillMid} />
        <path d="M0 0.5H8" className={styles.strokeInk} strokeWidth="0.6" />
      </pattern>
      {/* Dark engraved metal: ink with thin light lines. */}
      <pattern id={`${id}-dark`} width="6" height="3" patternUnits="userSpaceOnUse">
        <rect width="6" height="3" className={styles.fillInk} />
        <path d="M0 1.5H6" className={styles.strokeLight} strokeWidth="0.6" />
      </pattern>
    </defs>
  );
}

/* ---------------------------------------------------------------- viaduct */

/** Points on a circle, angle in degrees (0 = right, 90 = down, as SVG's y runs). */
function onCircle(cx: number, cy: number, r: number, degrees: number) {
  const a = (degrees * Math.PI) / 180;
  return [cx + r * Math.cos(a), cy + r * Math.sin(a)] as const;
}
const round = (n: number) => Math.round(n * 10) / 10;

const CX = BAY / 2;
/** Where the arches spring, their half-spans and their rises: a little taller than round. */
const SPRING = 212;
const PIER = 24;
const INNER = 98;
const OUTER = 120;
const INNER_RISE = 113;
const OUTER_RISE = 135;

/** A point on the arch at `degrees`, `t` of the way from its inner edge (0) to its outer (1). */
function onArch(degrees: number, t: number) {
  const a = (degrees * Math.PI) / 180;
  const rx = INNER + (OUTER - INNER) * t;
  const ry = INNER_RISE + (OUTER_RISE - INNER_RISE) * t;
  return [CX + rx * Math.cos(a), SPRING + ry * Math.sin(a)] as const;
}

/** The joints between the voussoirs of one arch. */
const JOINTS = Array.from({ length: 18 }, (_, i) => {
  const degrees = 180 + ((i + 1) * 180) / 19;
  const [x1, y1] = onArch(degrees, 0);
  const [x2, y2] = onArch(degrees, 1);
  return `M${round(x1)} ${round(y1)}L${round(x2)} ${round(y2)}`;
}).join('');
/** A short tooling mark in the middle of each voussoir. */
const STONE_MARKS = Array.from({ length: 19 }, (_, i) => {
  const degrees = 180 + ((i + 0.5) * 180) / 19;
  const [x1, y1] = onArch(degrees, 0.3);
  const [x2, y2] = onArch(degrees, 0.7);
  return `M${round(x1)} ${round(y1)}L${round(x2)} ${round(y2)}`;
}).join('');
/** The dentils under the parapet. */
const DENTILS = Array.from(
  { length: Math.floor(BAY / 9) },
  (_, i) => `M${i * 9 + 2} 48h5v8h-5z`,
).join('');

function Bay({ id }: { id: string }) {
  const p = (name: string) => `url(#${id}-${name})`;
  return (
    <g id={`${id}-bay`}>
      {/* The wall, all brick. */}
      <rect x="0" y="56" width={BAY} height={VIADUCT_HEIGHT - 56} fill={p('brick')} />
      {/* The vault under the arch, then the daylight through it, seen a little from the side. */}
      <path
        d={`M${CX - INNER} ${VIADUCT_HEIGHT}V${SPRING}A${INNER} ${INNER_RISE} 0 0 1 ${CX + INNER} ${SPRING}V${VIADUCT_HEIGHT}Z`}
        fill={p('dots')}
      />
      <path
        d={`M${CX - 2} ${VIADUCT_HEIGHT}V${SPRING - 37}A50 50 0 0 1 ${CX + INNER} ${SPRING - 37}V${VIADUCT_HEIGHT}Z`}
        className={styles.fillPaper}
      />
      <path
        d={`M${CX - 2} ${VIADUCT_HEIGHT}V${SPRING - 37}A50 50 0 0 1 ${CX + INNER} ${SPRING - 37}V${VIADUCT_HEIGHT}`}
        className={styles.strokeInk}
        fill="none"
        strokeWidth="1.4"
      />
      {/* Narrow slits at the vault's foot. */}
      <path
        d="M36 400v-34a2.5 2.5 0 0 1 5 0v34zM48 400v-38a2.5 2.5 0 0 1 5 0v38zM60 400v-34a2.5 2.5 0 0 1 5 0v34z"
        className={styles.fillDot}
      />
      {/* The ring of voussoirs, and its keystone. */}
      <path
        d={`M${CX - OUTER} ${SPRING}A${OUTER} ${OUTER_RISE} 0 0 1 ${CX + OUTER} ${SPRING}H${CX + INNER}A${INNER} ${INNER_RISE} 0 0 0 ${CX - INNER} ${SPRING}Z`}
        className={`${styles.fillLight} ${styles.strokeInk}`}
        strokeWidth="1.6"
      />
      <path d={JOINTS} className={styles.strokeInk} strokeWidth="1.3" />
      <path d={STONE_MARKS} className={styles.strokeInk} strokeWidth="2" />
      <path
        d={`M${CX - 9} ${SPRING - INNER_RISE + 1}L${CX - 13} ${SPRING - OUTER_RISE - 9}H${CX + 13}L${CX + 9} ${SPRING - INNER_RISE + 1}Z`}
        className={`${styles.fillLight} ${styles.strokeInk}`}
        strokeWidth="1.6"
      />
      <path
        d={`M${CX - 5} ${SPRING - INNER_RISE - 4}V${SPRING - OUTER_RISE - 4}M${CX + 5} ${SPRING - INNER_RISE - 4}V${SPRING - OUTER_RISE - 4}`}
        className={styles.strokeSoft}
        strokeWidth="1"
      />
      {/* The pier (its face, a brick column) with its impost. */}
      <rect
        x={-PIER}
        y={SPRING}
        width={PIER * 2}
        height={VIADUCT_HEIGHT - SPRING}
        fill={p('brick')}
      />
      <path
        d={`M${-PIER} ${SPRING}V${VIADUCT_HEIGHT}M${PIER} ${SPRING}V${VIADUCT_HEIGHT}`}
        className={styles.strokeInk}
        strokeWidth="1.8"
      />
      <rect
        x={-PIER - 6}
        y={SPRING - 3}
        width={PIER * 2 + 12}
        height="13"
        fill={p('vhatch')}
        className={styles.strokeInk}
        strokeWidth="1.4"
      />
      {/* The parapet: coping, balustrade, mouldings, dentils. */}
      <rect x="0" y="0" width={BAY} height="10" fill={p('vhatch')} />
      <rect x="0" y="12" width={BAY} height="26" fill={p('vhatch')} />
      <path
        d={`M0 0.8H${BAY}M0 10H${BAY}M0 12H${BAY}M0 25H${BAY}M0 38H${BAY}M0 42H${BAY}M0 46H${BAY}`}
        className={styles.strokeInk}
        strokeWidth="1.4"
      />
      <rect x="0" y="38" width={BAY} height="8" className={styles.fillLight} />
      <path d={`M0 42H${BAY}`} className={styles.strokeInk} strokeWidth="1" />
      <path d={DENTILS} className={styles.fillInk} />
      <path d={`M0 56H${BAY}`} className={styles.strokeInk} strokeWidth="1.4" />
      {/* The pilaster over the pier: a hatched block between two dark brackets. */}
      <rect
        x="-20"
        y="-3"
        width="40"
        height="62"
        fill={p('vhatch')}
        className={styles.strokeInk}
        strokeWidth="1.4"
      />
      <path d="M-27 0h7v66q-3.5 6-7 0zM20 0h7v66q-3.5 6-7 0z" fill={p('dots')} />
      <path
        d="M-27 0h7v66q-3.5 6-7 0zM20 0h7v66q-3.5 6-7 0z"
        fill="none"
        className={styles.strokeInk}
        strokeWidth="1.2"
      />
      <path d="M-12 59h24v12h-24z" fill={p('dots')} />
      <path d="M-20 18h40M-20 40h40" className={styles.strokeInk} strokeWidth="1.2" />
    </g>
  );
}

/** The viaduct: `count` bays from x = `start`, in units. */
export function Viaduct({ id, count, start }: { id: string; count: number; start: number }) {
  return (
    <>
      <Patterns id={`${id}v`} />
      <defs>
        <Bay id={`${id}v`} />
      </defs>
      {Array.from({ length: count }, (_, i) => (
        <use key={i} href={`#${id}v-bay`} x={start + i * BAY} data-part="bay" />
      ))}
      {/* The last pier, so the run ends on one. */}
      <use href={`#${id}v-bay`} x={start + count * BAY} clipPath={`url(#${id}v-end)`} />
      <clipPath id={`${id}v-end`}>
        <rect x={start + count * BAY - 30} y="-10" width="30" height={VIADUCT_HEIGHT + 10} />
      </clipPath>
    </>
  );
}

/* ---------------------------------------------------------------- train */

/** A wheel: dark tyre, a mid-tone web, six spokes, a hub. It rolls by a CSS variable (see the CSS). */
function Wheel({ x, y, r }: { x: number; y: number; r: number }) {
  const spokes = Array.from({ length: 6 }, (_, i) => {
    const [x1, y1] = onCircle(0, 0, r * 0.2, i * 60);
    const [x2, y2] = onCircle(0, 0, r * 0.8, i * 60);
    return `M${round(x1)} ${round(y1)}L${round(x2)} ${round(y2)}`;
  }).join('');
  return (
    <g transform={`translate(${x} ${y})`} data-part="wheel">
      <g className={styles.wheel} style={{ '--k': 57.3 / r } as CSSProperties}>
        <circle r={r} className={styles.fillInk} />
        <circle r={r * 0.78} className={styles.fillMid} />
        <path d={spokes} className={styles.strokeInk} strokeWidth={r > 14 ? 2.6 : 1.8} />
        <circle r={r * 0.22} className={styles.fillInk} />
      </g>
    </g>
  );
}

/** A buffer and coupling hook at a wagon's end, pointing `side` (−1 back, 1 forward). */
function Buffer({ x, side }: { x: number; side: -1 | 1 }) {
  return (
    <path
      d={`M${x} 151h${side * 7}M${x + side * 7} 147v8M${x} 145v12`}
      className={styles.strokeInk}
      strokeWidth="2.4"
    />
  );
}

/** An open goods wagon from `x`, `width` long, its sides `height` tall. */
function Wagon({ id, x, width, height }: { id: string; x: number; width: number; height: number }) {
  const top = 163 - height;
  const third = width / 3;
  const p = (name: string) => `url(#${id}-${name})`;
  return (
    <g data-part="wagon">
      <Buffer x={x} side={-1} />
      <Buffer x={x + width} side={1} />
      <Wheel x={x + 30} y={171} r={9} />
      <Wheel x={x + width - 30} y={171} r={9} />
      <rect x={x + 8} y="161" width={width - 16} height="6" className={styles.fillInk} />
      <rect x={x} y={top} width={width} height={height} fill={p('planks')} />
      {/* Corner braces in the end panels, posts between them. */}
      <path
        d={`M${x + 4} ${top + 3}L${x + third - 2} 160M${x + width - 4} ${top + 3}L${x + width - third + 2} 160`}
        className={styles.strokeInk}
        strokeWidth="3.4"
      />
      <path
        d={`M${x + third} ${top}V163M${x + third * 2} ${top}V163${width > 160 ? `M${x + width / 2 - 6} ${top}V163M${x + width / 2 + 6} ${top}V163` : ''}`}
        className={styles.strokeInk}
        strokeWidth="3.6"
      />
      <path
        d={`M${x + third + 2.5} ${top + 2}V161M${x + third * 2 + 2.5} ${top + 2}V161`}
        className={styles.strokeLight}
        strokeWidth="1"
      />
      <rect
        x={x + 2}
        y={top + 1}
        width={width - 4}
        height={height - 2}
        className={styles.strokeInk}
        strokeWidth="4"
      />
      <path
        d={`M${x + 5} ${top + 4}H${x + width - 5}`}
        className={styles.strokeLight}
        strokeWidth="1"
      />
    </g>
  );
}

export function Train({ id }: { id: string }) {
  const p = (name: string) => `url(#${id}-${name})`;
  return (
    <>
      <Patterns id={id} />
      {/* Couplings. */}
      <path
        d="M184 151H195M365 151H378M574 151H580"
        className={styles.strokeInk}
        strokeWidth="2.4"
      />
      <Wagon id={id} x={10} width={167} height={46} />
      <Wagon id={id} x={202} width={156} height={36} />

      {/* The tender: slatted sides under a rim, a heap of coal, a dark frame on small wheels. */}
      <g data-part="tender">
        <Buffer x={387} side={-1} />
        <Wheel x={408} y={172} r={8} />
        <Wheel x={432} y={172} r={8} />
        <Wheel x={520} y={172} r={8} />
        <Wheel x={544} y={172} r={8} />
        <rect x="389" y="150" width="176" height="18" fill={p('dark')} />
        <path
          d="M400 112q6-9 15-6q5-8 14-4q7-8 16-3q8-9 17-3q9-7 17 0q9-7 17 1q8-6 16 1q8-5 15 2q6-3 12 4l3 8H400z"
          fill={p('dark')}
          className={styles.strokeInk}
          strokeWidth="1.4"
        />
        <path
          d="M410 108q4-3 8 0M432 102q4-3 8 0M452 104q4-3 8 0M476 100q4-3 8 0M498 103q4-3 8 0M520 104q4-3 8 0M542 107q4-3 8 0"
          className={styles.strokeLight}
          strokeWidth="1.1"
        />
        <rect x="385" y="113" width="182" height="7" className={styles.fillInk} />
        <rect x="388" y="120" width="176" height="31" fill={p('slats')} />
        <rect
          x="388"
          y="120"
          width="176"
          height="31"
          className={styles.strokeInk}
          strokeWidth="2"
        />
        <path d="M388 147H564M388 124H564" className={styles.strokeInk} strokeWidth="1.6" />
      </g>

      {/* The engine. */}
      <g data-part="engine">
        <Wheel x={606} y={172} r={8} />
        <Wheel x={668} y={164} r={16} />
        <Wheel x={720} y={164} r={16} />
        <Wheel x={808} y={171} r={9} />
        {/* Frame and running board. */}
        <rect x="578" y="150" width="266" height="12" fill={p('dark')} />
        <rect x="574" y="146" width="272" height="5" className={styles.fillInk} />
        <path d="M600 158H840" className={styles.strokeLight} strokeWidth="1" />
        {/* Cab, with a plated side, a window pair and a name panel, under an overhanging roof. */}
        <rect x="580" y="92" width="66" height="54" fill={p('boiler')} />
        <rect x="586" y="99" width="12" height="20" fill={p('dark')} />
        <rect x="602" y="99" width="22" height="20" className={styles.fillMid} />
        <path d="M602 104h22M602 110h22M613 99v20" className={styles.strokeInk} strokeWidth="1.2" />
        <rect x="586" y="126" width="54" height="16" fill={p('slats')} />
        <rect
          x="580"
          y="92"
          width="66"
          height="54"
          className={styles.strokeInk}
          strokeWidth="2.4"
        />
        <path d="M580 123H646" className={styles.strokeInk} strokeWidth="1.6" />
        <path d="M572 85h80v6h-80z" className={styles.fillInk} />
        <path d="M576 89h72" className={styles.strokeLight} strokeWidth="1" />
        <path d="M642 72v13" className={styles.strokeInk} strokeWidth="2.4" />
        {/* Boiler, its bands and a sloping footplate skirt. */}
        <rect x="646" y="114" width="180" height="34" fill={p('boiler')} />
        <path d="M646 114H826" className={styles.strokeInk} strokeWidth="2.4" />
        <path
          d="M690 114V146M740 114V146M786 114V146"
          className={styles.strokeInk}
          strokeWidth="2.6"
        />
        <path
          d="M693 116V146M743 116V146M789 116V146"
          className={styles.strokeLight}
          strokeWidth="1"
        />
        <path d="M652 146L700 128H780" className={styles.strokeInk} strokeWidth="2" />
        {/* The tall sand box behind the cab, the steam dome, the safety valve. */}
        <path
          d="M650 114V86h24v28z"
          fill={p('boiler')}
          className={styles.strokeInk}
          strokeWidth="2"
        />
        <path d="M647 84h30v5h-30z" className={styles.fillInk} />
        <path d="M656 84v-5h12v5z" className={styles.fillInk} />
        <path
          d="M760 114V94q10-8 20 0v20z"
          fill={p('boiler')}
          className={styles.strokeInk}
          strokeWidth="2"
        />
        <path d="M758 92h24v4h-24z" className={styles.fillInk} />
        <path d="M712 114v-9h6v9z" className={styles.fillInk} />
        {/* Chimney: a tall stack with a capped mouth. */}
        <path
          d={`M${CHIMNEY.x - 9} 114V${CHIMNEY.y + 14}h18V114z`}
          fill={p('dark')}
          className={styles.strokeInk}
          strokeWidth="2.2"
        />
        <path
          d={`M${CHIMNEY.x - 13} ${CHIMNEY.y + 14}L${CHIMNEY.x - 11} ${CHIMNEY.y}H${CHIMNEY.x + 11}L${CHIMNEY.x + 13} ${CHIMNEY.y + 14}z`}
          className={styles.fillInk}
        />
        <path
          d={`M${CHIMNEY.x - 4} ${CHIMNEY.y + 18}V112`}
          className={styles.strokeLight}
          strokeWidth="1.2"
        />
        {/* Smokebox front, lamp, buffer beam and guard. */}
        <rect
          x="826"
          y="110"
          width="12"
          height="38"
          fill={p('dark')}
          className={styles.strokeInk}
          strokeWidth="1.6"
        />
        <rect x="830" y="100" width="7" height="9" className={styles.fillInk} />
        <rect x="838" y="140" width="8" height="18" className={styles.fillInk} />
        <path d="M846 147h8M854 143v8" className={styles.strokeInk} strokeWidth="2.6" />
        <path d="M832 162L850 178H822z" fill={p('dark')} />
        {/* Cylinder and the coupling rod, carried round on the crank pins. */}
        <rect
          x="770"
          y="150"
          width="34"
          height="16"
          rx="2"
          fill={p('boiler')}
          className={styles.strokeInk}
          strokeWidth="1.8"
        />
        <path d="M770 158H736" className={styles.strokeInk} strokeWidth="2.4" />
        <g className={styles.rod}>
          <rect
            x="664"
            y="161"
            width="60"
            height="5"
            rx="2.5"
            className={`${styles.fillLight} ${styles.strokeInk}`}
            strokeWidth="1.6"
          />
        </g>
      </g>
    </>
  );
}

/* ---------------------------------------------------------------- smoke */

type Circle = [number, number, number];

/**
 * A cumulus of small lobes in the 140 × 100 box: a ring of lobes round an ellipse, larger over the
 * top than under the foot, and a few big ones filling the middle. `seed` varies the heap.
 */
function cloud(seed: number): Circle[] {
  const rand = (n: number) => {
    const x = Math.sin(seed * 91.7 + n * 47.3) * 9631.17;
    return x - Math.floor(x);
  };
  const lobes: Circle[] = [
    [54, 50, 24],
    [84, 48, 24],
    [70, 60, 22],
  ];
  const count = 8;
  for (let i = 0; i < count; i++) {
    const degrees = (i / count) * 360 + rand(i) * 14;
    const top = Math.sin((degrees * Math.PI) / 180) < 0;
    const [x, y] = onCircle(70, 52, 1, degrees);
    const r = top ? 16 + rand(i + 20) * 8 : 12 + rand(i + 20) * 5;
    lobes.push([round(70 + (x - 70) * 42), round(52 + (y - 52) * (top ? 24 : 18)), round(r)]);
  }
  // Back to front: the upper lobes first, so each lower one laps over them.
  return lobes.sort((a, b) => a[1] - b[1]);
}
const CLOUDS = [cloud(1), cloud(2), cloud(3)];

/** The engraved shading of one lobe: arcs inside its foot, its upper rim, and now and then a curl. */
function shading([x, y, r]: Circle, i: number) {
  const arc = (radius: number, from: number, to: number) => {
    const [x1, y1] = onCircle(x, y, r * radius, from);
    const [x2, y2] = onCircle(x, y, r * radius, to);
    const rr = round(r * radius);
    return `M${round(x1)} ${round(y1)}A${rr} ${rr} 0 0 1 ${round(x2)} ${round(y2)}`;
  };
  return (
    arc(0.86, 5, 125) +
    arc(0.73, 20, 115) +
    arc(0.6, 35, 105) +
    (r > 17 ? arc(0.47, 50, 95) : '') +
    // The lobe's own upper rim, where it billows out of the heap.
    arc(1, i % 2 ? 190 : 200, i % 2 ? 290 : 270) +
    (i % 3 === 0 ? arc(0.55, 205, 255) : '')
  );
}

export function PuffSymbols({ id }: { id: string }) {
  return (
    <defs>
      {CLOUDS.map((circles, i) => (
        <symbol key={i} id={`${id}-puff${i}`} viewBox={`0 0 ${PUFF_WIDTH} ${PUFF_HEIGHT}`}>
          {/* The heap's outline (its lobes stroked, then all filled over), then the engraving. */}
          <g className={`${styles.fillInk} ${styles.strokeInk}`} strokeWidth="2.6">
            {circles.map(([x, y, r], j) => (
              <circle key={j} cx={x} cy={y} r={r} />
            ))}
          </g>
          <g className={styles.fillPaper}>
            {circles.map(([x, y, r], j) => (
              <circle key={j} cx={x} cy={y} r={r} />
            ))}
          </g>
          <path
            d={circles.map(shading).join('')}
            className={styles.strokeInk}
            strokeWidth="1.25"
            strokeLinecap="round"
          />
        </symbol>
      ))}
    </defs>
  );
}
export const PUFF_KINDS = CLOUDS.length;
```

### train-footer/use-autoplay.ts

```tsx
'use client';

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

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input. `visible` and `reduced` are there for parts
 * that only need to know one of them.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.1 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```

### train-footer/train-footer.module.css

```css
/* Train footer (train-footer.tsx): words and links on white paper, a wide sky, and an engraved
   viaduct in sepia ink along the bottom, which a steam train crosses now and then. At night the
   paper turns near-black and the engraving pale sepia.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Sizes. The drawing is in units (1 unit = 1 px at full size); the scene scales it by --s, which
   the component sets from the footer's width. The words step down a little on narrower footers
   (--t, set on the content by container queries on the root).

   Memory. The train and the puffs of smoke move only by transform and opacity, each on a layer of
   its own; the viaduct never repaints. */

.root {
  /* Paper, words, links, small print and the top rule. */
  --paper: #fdfdfd;
  --text: #121010;
  --body: #474645;
  --link: #484745;
  --muted: #716f6e;
  --rule: #dcdcdc;
  /* The engraving: ink lines, dark masses, the dots pricked in them, soft tooling marks and
     light faces. */
  --ink: #46281a;
  --shade: #4a2a18;
  --dot: #cdb9aa;
  --soft: #a68b79;
  --light: #fdfdfd;
  /* The train's mid-tone: wagon boards, boiler plate. */
  --mid: #a08c81;
  --focus: #387bff;
  --t: 1;
  position: relative;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  width: 100%;
  min-height: 100%;
  overflow: hidden;
  overflow: clip;
  background: var(--paper);
  color: var(--text);
  font-family:
    -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --paper: light-dark(#fdfdfd, #14110f);
    --text: light-dark(#121010, #f1ece6);
    --body: light-dark(#474645, #c9c2ba);
    --link: light-dark(#484745, #bcb4ab);
    --muted: light-dark(#716f6e, #8c847c);
    --rule: light-dark(#dcdcdc, #2b2622);
    --ink: light-dark(#46281a, #d4b797);
    --shade: light-dark(#4a2a18, #3a2a1f);
    --dot: light-dark(#cdb9aa, #1a130e);
    --soft: light-dark(#a68b79, #7d6250);
    --light: light-dark(#fdfdfd, #14110f);
    --mid: light-dark(#a08c81, #5e4636);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}

/* ---------------------------------------------------------------- words */

.rule {
  height: 1px;
  flex: none;
  background: var(--rule);
  transform-origin: 0 50%;
}

.content {
  display: grid;
  grid-template-columns: minmax(0, 640fr) minmax(0, 778fr);
  width: min(100% - 2 * clamp(20px, 5cqi, 64px), 1418px);
  margin-inline: auto;
  padding-top: 58px;
}

.about {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-width: 0;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: calc(16px * var(--t));
  margin-top: 2px;
  border-radius: 6px;
  color: var(--text);
  font-family: ui-serif, 'New York', 'Iowan Old Style', Georgia, serif;
  font-size: calc(36px * var(--t));
  font-weight: 600;
  line-height: 44px;
  letter-spacing: -0.01em;
  text-decoration: none;
}
.mark {
  width: calc(31px * var(--t));
  height: calc(31px * var(--t));
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.1;
}

.tagline {
  max-width: 24em;
  margin: 23px 0 0;
  color: var(--body);
  font-size: calc(17.6px * var(--t));
  line-height: calc(26px * var(--t));
  text-wrap: pretty;
}

.copyright {
  margin: 14px 0 0;
  color: var(--muted);
  font-size: calc(16px * var(--t));
  line-height: 22px;
}

.columns {
  display: grid;
  grid-template-columns: minmax(0, 268fr) minmax(0, 268fr) minmax(0, 242fr);
}
.column {
  min-width: 0;
}

.heading {
  margin: 0;
  color: var(--text);
  font-family: ui-serif, 'New York', 'Iowan Old Style', Georgia, serif;
  font-size: calc(23px * var(--t));
  font-weight: 700;
  line-height: 28px;
  padding-bottom: 4px;
}

.links {
  display: grid;
  gap: calc(13px * var(--t));
  margin: calc(13px * var(--t)) 0 0;
  padding: 0;
  list-style: none;
}

.link {
  display: inline-block;
  border-radius: 4px;
  color: var(--link);
  font-size: calc(20.5px * var(--t));
  line-height: 32px;
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 1px;
  text-underline-offset: 4px;
  transition:
    color 160ms ease,
    text-decoration-color 160ms ease;
}
@media (hover: hover) {
  .link:hover {
    color: var(--text);
    text-decoration-color: currentColor;
  }
  .brand:hover .mark {
    transform: rotate(45deg);
  }
}
.mark {
  transition: transform 500ms cubic-bezier(0.3, 0.7, 0.2, 1);
}

.brand:focus-visible,
.link:focus-visible,
.toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

@container (max-width: 1199px) {
  .content {
    --t: 0.88;
  }
}
@container (max-width: 859px) {
  .content {
    --t: 0.82;
  }
  .content {
    grid-template-columns: minmax(0, 1fr);
    row-gap: 32px;
    padding-top: 40px;
  }
  .columns {
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    gap: 28px 20px;
  }
}
@container (max-width: 359px) {
  .content {
    --t: 0.76;
  }
}

/* The reveal, the first time the footer is seen: the rule draws itself from the left, then the
   words rise in one after another (--i is each one's place in line). */
.root[data-reveal='waiting'] .rule {
  transform: scaleX(0);
}
.root[data-reveal='waiting'] :is(.brand, .tagline, .copyright, .heading, .links > li) {
  opacity: 0;
  transform: translateY(8px);
}
.root[data-reveal='shown'] .rule {
  transition: transform 700ms cubic-bezier(0.2, 0.75, 0.25, 1);
}
.root[data-reveal='shown'] :is(.brand, .tagline, .copyright, .heading, .links > li) {
  transition:
    opacity 420ms ease-out calc(var(--i) * 55ms),
    transform 520ms cubic-bezier(0.2, 0.7, 0.2, 1) calc(var(--i) * 55ms);
}

/* ---------------------------------------------------------------- scene */

.scene {
  position: relative;
  flex: 1 0 auto;
  min-height: calc(868px * var(--s));
}

.stage {
  position: absolute;
  left: 0;
  width: 0;
  height: 0;
  transform: scale(var(--s));
  transform-origin: 0 0;
}

.defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.puff {
  position: absolute;
  top: 0;
  left: 0;
  width: 140px;
  height: 100px;
  opacity: 0;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
  pointer-events: none;
}
.puff > svg,
.train > svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: none;
}

.train {
  position: absolute;
  top: -180px;
  left: 0;
  width: 856px;
  height: 180px;
  transform: translate3d(-900px, 0, 0);
  will-change: transform;
}

.viaduct {
  position: absolute;
  bottom: 0;
  left: 0;
  display: block;
  width: 100%;
  height: calc(400px * var(--s));
  fill: none;
}

/* The engraving's paints. Line-only shapes inherit fill: none from their svg. */
.strokeInk {
  stroke: var(--ink);
}
.strokeSoft {
  stroke: var(--soft);
}
.strokeLight {
  stroke: var(--light);
}
.fillInk {
  fill: var(--ink);
}
.fillShade {
  fill: var(--shade);
}
.fillDot {
  fill: var(--dot);
}
.fillLight {
  fill: var(--light);
}
.fillMid {
  fill: var(--mid);
}
.fillPaper {
  fill: var(--paper);
}

/* Wheels turn by the distance travelled (--roll, in units; --k, degrees per unit for their
   radius); the coupling rod rides round on the crank pins, 7 units out. */
.wheel {
  transform-box: fill-box;
  transform-origin: center;
  transform: rotate(calc(var(--roll, 0) * var(--k) * 1deg));
}
.rod {
  transform: translate(
    calc(cos(var(--roll, 0) * 2.6deg) * 7px),
    calc(sin(var(--roll, 0) * 2.6deg) * 7px)
  );
}

/* The pause button: out of sight until it has the keyboard's focus. A click on the scene does
   the same. */
.toggle {
  position: absolute;
  right: 16px;
  bottom: 16px;
  padding: 8px 14px;
  border: 0;
  border-radius: 999px;
  background: var(--text);
  color: var(--paper);
  font: inherit;
  font-size: 14px;
  clip-path: inset(50%);
  opacity: 0;
}
.toggle:focus-visible {
  clip-path: none;
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .mark,
  .link {
    transition: none;
  }
}
```