Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Train footer

New

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

train-footer/train-footer.tsx
Live · interactive

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

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

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

Into components/train-footer of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/train-footer.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

site-footer.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' }] },      ]}    />  );}

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.

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.

  • 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.
PropTypeDefaultDescription
brandstring'waymark'The name beside the wheel mark.
taglinestring—The line of copy under the brand.
copyrightstring—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.
speednumber295The train’s average speed across the viaduct, in px a second at full size.
autoplaybooleantrueRun the train on a loop while the footer is on screen; false, it crosses once each time the footer comes into view.
stillnumber—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''system'Light or dark, or the page’s own color-scheme.
classNamestring—Classes for the footer element.

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

train-footer.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.
art.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;
use-autoplay.ts
'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]) => {
train-footer.module.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
  • 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.