Train footer
NewA site footer over an engraved brick viaduct, which a little steam train crosses in a plume of smoke.
train-footer/train-footer.tsxThe 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:
npx shadcn@latest add https://brunodelorence.com/r/train-footer.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
brand | string | '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 | 295 | The train’s average speed across the viaduct, in px a second at full size. |
autoplay | boolean | 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' | 'system' | Light or dark, or the page’s own color-scheme. |
className | string | — | 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.
// 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.// 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 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 (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.