Tokyo Subway how it’s made

The idea

You are standing in a Tokyo metro car at night. Three windows show tunnel lamps streaming past at two depths, hanging straps sway, and every 24 seconds the train brakes into a station: a platform and a 銀座 sign slide into the windows, dwell, then slide away as the lamps dim and return. It is one HTML file, one CSS file, and no JavaScript.

Techniques

Period-locked repeating-gradient tile loop

The lamp streaks are not elements. Each lane is a repeat-x gradient tile on an oversized layer, and the layer is translated by exactly one tile width. Frame 0 and the last frame are pixel-identical, so the loop has no seam and needs no steps() or JS reset.

.near {
  inline-size: calc(100% + var(--tile-near));
  /* background-image (lamp gradients) omitted */
  background-size: var(--tile-near) 8px, var(--tile-near) 2px;
  animation: tile-near var(--t-rush) linear infinite, dip var(--t-drift) linear infinite;
}
@keyframes tile-far { to { translate: calc(var(--tile-far) * -1); } }
@keyframes tile-near { to { translate: calc(var(--tile-near) * -1); } }

Three rules make it work. The layer is wider than the viewport by one tile (100% + var(--tile-near)), so no gap shows as it slides. The keyframe distance equals the tile size, read from the same custom property. The easing is linear. The far lane (--tile-far: 120px) takes calc(var(--t-rush) * 3), 4.8s; the near lane (--tile-near: 480px) takes --t-rush, 1.6s.

Why it is cheap: the gradient is painted once into a layer, and animating translate only moves that layer on the compositor. The main thread is idle after the first paint. This claim rests on how compositor-driven translate animations work; frame times are not yet measured (see Performance).

Honest trade-off: the parallax is a 12x speed ratio (120px per 4.8s = 25px/s versus 480px per 1.6s = 300px/s), not the roughly 3x the brief suggested. It reads as fast-train depth, but it is not a subtle drift. Because each speed is tile / period, you cannot change one tile size without changing its period, or the loop breaks.

Support: individual transform properties (translate) are Baseline widely available. Gradients are universal.

Ratio-locked parallax

The two lanes share one --t-rush token, with the far lane at an integer multiple of it. They re-align on a common period instead of drifting apart, and the ratio can be tuned in one place.

mask-image window cutouts

.windows is a single element filled with the tunnel color. A repeating gradient mask punches three openings:

--holes: repeating-linear-gradient(90deg, #000 0 30.4%, transparent 30.4% 33.333%);

One element and one mask replace three windows. The pillars between them are .stage::before, a darker band behind the masked element. Support: mask-image is Baseline widely available (2023-12-07).

light-dark()

The sign text uses light-dark() so one declaration covers both schemes:

color: light-dark(var(--c-ink), var(--c-paper));
background: var(--c-line);

Support: Baseline newly available (2024-05-13). No @supports not (color: light-dark(...)) fallback exists, although the brief called for one. In an older browser the whole color declaration is dropped, and the sign text inherits --c-ink from body.

sibling-index() stagger with --i fallback

Eight straps sway out of phase. Each gets a negative delay from its position, so the motion is already mid-cycle at load:

animation-delay: calc(var(--i) * -0.4s);
animation-delay: calc(sibling-index() * -0.4s);

The first line is the fallback. Each <i> carries an inline --i. A browser that does not understand sibling-index() drops the second declaration as invalid at parse time and keeps the first. Where it is supported, the second wins and the inline --i is redundant. Support: sibling-index() is Baseline newly available (2026-08-18). The --i fallback keeps older browsers visually identical.

Performance notes

Measured by the build:

rawgzipbrotlibudgetok
6,803 B2,270 B1,947 B14,336 Byes

Not yet measured: Lighthouse scores, frame times and dropped frames, and layer counts from the browser. pnpm measure does not exist yet. TODO: fill these in when it does.

Known deviations, from review (not fixed):

Accessibility

Reuse

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Tokyo Subway</title>
<!--css-->
<main class="stage" aria-label="Inside a Tokyo metro car at night, tunnel lights streaming past the windows">
<div class="windows" aria-hidden="true"><div class="far"></div><div class="near"></div><div class="station"><b class="sign" lang="ja">銀座</b></div></div>
<div class="glass" aria-hidden="true"></div>
<div class="interior" aria-hidden="true"></div>
<div class="straps" aria-hidden="true"><i style="--i:1"></i><i style="--i:2"></i><i style="--i:3"></i><i style="--i:4"></i><i style="--i:5"></i><i style="--i:6"></i><i style="--i:7"></i><i style="--i:8"></i></div>
</main>
@import "lib/stage";

@layer scene {
  .stage {
    --tile-far: 120px;
    --tile-near: 480px;
    --holes: repeating-linear-gradient(90deg, #000 0 30.4%, transparent 30.4% 33.333%);
    background: linear-gradient(var(--c-car-wall), color-mix(in oklab, var(--c-car-wall), var(--c-ink) 12%));
  }
  /* pillars/frames between windows: one dark band behind the masked windows */
  .stage::before {
    content: "";
    position: absolute;
    inset: calc(16% - 8px) calc(3% - 8px) calc(34% - 8px);
    border-radius: 10px;
    background: color-mix(in oklab, var(--c-car-wall), var(--c-ink) 28%);
  }
  .windows {
    position: absolute;
    inset: 16% 3% 34%;
    overflow: clip;
    background: var(--c-tunnel);
    mask-image: var(--holes);
  }
  .far, .near {
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    background-repeat: repeat-x;
  }
  .far {
    inline-size: calc(100% + var(--tile-far));
    background-image:
      linear-gradient(90deg, transparent, var(--c-lamp) 60%, transparent),
      linear-gradient(90deg, transparent, var(--c-lamp) 60%, transparent);
    background-size: var(--tile-far) 3px, var(--tile-far) 3px;
    background-position: 0 30%, 40px 62%;
    opacity: 0.7;
    animation: tile-far calc(var(--t-rush) * 3) linear infinite, dip var(--t-drift) linear infinite;
  }
  .near {
    inline-size: calc(100% + var(--tile-near));
    background-image:
      linear-gradient(90deg, transparent, var(--c-lamp) 50%, transparent),
      linear-gradient(90deg, color-mix(in oklab, var(--c-lamp) 30%, transparent) 0 70%, transparent 70%);
    background-size: var(--tile-near) 8px, var(--tile-near) 2px;
    background-position: 0 14%, 0 86%;
    animation: tile-near var(--t-rush) linear infinite, dip var(--t-drift) linear infinite;
  }
  @keyframes tile-far { to { translate: calc(var(--tile-far) * -1); } }
  @keyframes tile-near { to { translate: calc(var(--tile-near) * -1); } }
  @keyframes dip {
    0%, 58%, 92%, 100% { opacity: 1; }
    66%, 84% { opacity: 0.25; }
  }

  .station {
    position: absolute;
    inset: 0;
    opacity: 0;
    background:
      repeating-linear-gradient(90deg, transparent 0 14%, color-mix(in oklab, var(--c-platform), var(--c-ink) 55%) 14% 17%, transparent 17% 33.333%),
      linear-gradient(var(--c-platform) 55%, color-mix(in oklab, var(--c-platform), var(--c-ink) 35%) 55%);
    animation: stop var(--t-drift) var(--ease-out) infinite;
  }
  @keyframes stop {
    0%, 60% { opacity: 0; translate: 40%; }
    70% { opacity: 1; translate: 0; }
    84% { opacity: 1; translate: 0; animation-timing-function: ease-in; }
    92%, 100% { opacity: 0; translate: -40%; }
  }
  .sign {
    position: absolute;
    inset-inline-start: 50%;
    inset-block-start: 14%;
    translate: -50%;
    padding: 0.2em 0.7em;
    font: 700 clamp(0.9rem, 3.4vmin, 2rem) system-ui, sans-serif;
    letter-spacing: 0.2em;
    color: light-dark(var(--c-ink), var(--c-paper));
    background: var(--c-line);
    border-radius: 3px;
    box-shadow: 0 0 2em color-mix(in oklab, var(--c-accent) 60%, transparent);
  }

  .glass {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
      linear-gradient(115deg, transparent 30%, color-mix(in oklab, var(--c-accent) 14%, transparent) 42%, transparent 52%),
      radial-gradient(ellipse at 50% 40%, transparent 55%, color-mix(in oklab, var(--c-ink) 30%, transparent));
  }

  .interior {
    position: absolute;
    inset: auto 0 0;
    block-size: 24%;
    background:
      linear-gradient(var(--c-line) 0 10px, transparent 10px),
      linear-gradient(color-mix(in oklab, var(--c-seat), var(--c-ink) 12%), var(--c-seat) 30%, color-mix(in oklab, var(--c-seat), var(--c-ink) 20%));
  }

  .straps {
    position: absolute;
    inset: 9% 0 auto;
    block-size: 6px;
    background: linear-gradient(color-mix(in oklab, var(--c-paper), var(--c-ink) 40%), color-mix(in oklab, var(--c-car-wall), var(--c-ink) 55%));
  }
  .straps i {
    position: absolute;
    inset-block-start: 6px;
    inset-inline-start: calc(var(--i) * 88% / var(--n, 8) + 1%);
    inline-size: 4vmin;
    block-size: 20vh;
    transform-origin: 50% 0;
    background: linear-gradient(var(--c-line), var(--c-line)) 50% 0 / 2px calc(100% - 4vmin) no-repeat;
    animation: sway 3.2s ease-in-out infinite alternate;
    animation-delay: calc(var(--i) * -0.4s);
    animation-delay: calc(sibling-index() * -0.4s);
  }
  .straps i::after {
    content: "";
    position: absolute;
    inset: auto 0 0;
    aspect-ratio: 1;
    border: 0.6vmin solid var(--c-line);
    border-radius: 50%;
  }
  @keyframes sway { from { rotate: -1.5deg; } to { rotate: 1.5deg; } }

  @media (prefers-reduced-motion: reduce) {
    .far, .near, .station, .straps i { animation: none; }
    .far { translate: -40px; }
    .near { translate: -170px; }
    .straps i { rotate: 0deg; }
  }
}

What ships: tokens and lib snippets inlined, lowered for the browser targets (minified in the real page).

@property --c-ink {
  syntax: "<color>";
  inherits: true;
  initial-value: #14121f;
}

@property --c-paper {
  syntax: "<color>";
  inherits: true;
  initial-value: #f4f1ff;
}

@property --c-accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #6d4aff;
}

@property --c-car-wall {
  syntax: "<color>";
  inherits: true;
  initial-value: #e9e6dc;
}

@property --c-seat {
  syntax: "<color>";
  inherits: true;
  initial-value: #c9c4b4;
}

@property --c-line {
  syntax: "<color>";
  inherits: true;
  initial-value: #ff9500;
}

@property --c-tunnel {
  syntax: "<color>";
  inherits: true;
  initial-value: #05050a;
}

@property --c-lamp {
  syntax: "<color>";
  inherits: true;
  initial-value: #ffe9b0;
}

@property --c-platform {
  syntax: "<color>";
  inherits: true;
  initial-value: #dfe9ff;
}

@property --a-spin {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@layer tokens {
  :root {
    color-scheme: light dark;
    --c-ink: light-dark(#14121f, #f4f1ff);
    --c-paper: light-dark(#f4f1ff, #0b0a14);
    --c-accent: light-dark(#6d4aff, #9d85ff);
    --c-car-wall: light-dark(#e9e6dc, #1a1826);
    --c-seat: light-dark(#c9c4b4, #2a2638);
    --c-line: light-dark(#ff9500, #ff9f1a);
    --c-tunnel: #05050a;
    --c-lamp: #ffe9b0;
    --c-platform: light-dark(#dfe9ff, #9fb4e8);
    --t-fast: .15s;
    --t-base: .4s;
    --t-slow: 1.2s;
    --t-drift: 24s;
    --t-rush: 1.6s;
    --t-swing: 3.6s;
    --ease-out: cubic-bezier(.22, 1, .36, 1);
    --ease-spring: linear(0, .22 2.1%, .73 6.5%, 1.04 10.7%, 1.09 13%, 1.02 20%, .99 27%, 1);
  }
}

@layer reset {
  *, :before, :after {
    box-sizing: border-box;
    margin: 0;
  }

  html {
    block-size: 100%;
  }
}

@layer base {
  body {
    background: var(--c-paper);
    min-block-size: 100dvh;
    color: var(--c-ink);
    font: 1rem / 1.5 system-ui, sans-serif;
    overflow: clip;
  }

  .stage {
    contain: strict;
    position: fixed;
    inset: 0;
    overflow: clip;
  }
}

@layer scene {
  .stage {
    --tile-far: 120px;
    --tile-near: 480px;
    --holes: repeating-linear-gradient(90deg, #000 0 30.4%, transparent 30.4% 33.333%);
    background: linear-gradient(var(--c-car-wall), color-mix(in oklab, var(--c-car-wall), var(--c-ink) 12%));
  }

  .stage:before {
    content: "";
    background: color-mix(in oklab, var(--c-car-wall), var(--c-ink) 28%);
    border-radius: 10px;
    position: absolute;
    inset: calc(16% - 8px) calc(3% - 8px) calc(34% - 8px);
  }

  .windows {
    background: var(--c-tunnel);
    mask-image: var(--holes);
    position: absolute;
    inset: 16% 3% 34%;
    overflow: clip;
  }

  .far, .near {
    background-repeat: repeat-x;
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
  }

  .far {
    inline-size: calc(100% + var(--tile-far));
    background-image: linear-gradient(90deg, transparent, var(--c-lamp) 60%, transparent),
      linear-gradient(90deg, transparent, var(--c-lamp) 60%, transparent);
    background-size: var(--tile-far) 3px, var(--tile-far) 3px;
    opacity: .7;
    animation: tile-far calc(var(--t-rush) * 3) linear infinite, dip var(--t-drift) linear infinite;
    background-position: 0 30%, 40px 62%;
  }

  .near {
    inline-size: calc(100% + var(--tile-near));
    background-image: linear-gradient(90deg, transparent, var(--c-lamp) 50%, transparent),
      linear-gradient(90deg, color-mix(in oklab, var(--c-lamp) 30%, transparent) 0 70%, transparent 70%);
    background-size: var(--tile-near) 8px, var(--tile-near) 2px;
    animation: tile-near var(--t-rush) linear infinite, dip var(--t-drift) linear infinite;
    background-position: 0 14%, 0 86%;
  }

  @keyframes tile-far {
    to {
      translate: calc(var(--tile-far) * -1);
    }
  }

  @keyframes tile-near {
    to {
      translate: calc(var(--tile-near) * -1);
    }
  }

  @keyframes dip {
    0%, 58%, 92%, 100% {
      opacity: 1;
    }

    66%, 84% {
      opacity: .25;
    }
  }

  .station {
    opacity: 0;
    background: repeating-linear-gradient(90deg, transparent 0 14%, color-mix(in oklab, var(--c-platform), var(--c-ink) 55%) 14% 17%, transparent 17% 33.333%),
      linear-gradient(var(--c-platform) 55%, color-mix(in oklab, var(--c-platform), var(--c-ink) 35%) 55%);
    animation: stop var(--t-drift) var(--ease-out) infinite;
    position: absolute;
    inset: 0;
  }

  @keyframes stop {
    0%, 60% {
      opacity: 0;
      translate: 40%;
    }

    70% {
      opacity: 1;
      translate: 0;
    }

    84% {
      opacity: 1;
      animation-timing-function: ease-in;
      translate: 0;
    }

    92%, 100% {
      opacity: 0;
      translate: -40%;
    }
  }

  .sign {
    letter-spacing: .2em;
    color: light-dark(var(--c-ink), var(--c-paper));
    background: var(--c-line);
    box-shadow: 0 0 2em color-mix(in oklab, var(--c-accent) 60%, transparent);
    border-radius: 3px;
    padding: .2em .7em;
    font: 700 clamp(.9rem, 3.4vmin, 2rem) system-ui, sans-serif;
    position: absolute;
    inset-block-start: 14%;
    inset-inline-start: 50%;
    translate: -50%;
  }

  .glass {
    pointer-events: none;
    background: linear-gradient(115deg, transparent 30%, color-mix(in oklab, var(--c-accent) 14%, transparent) 42%, transparent 52%),
      radial-gradient(ellipse at 50% 40%, transparent 55%, color-mix(in oklab, var(--c-ink) 30%, transparent));
    position: absolute;
    inset: 0;
  }

  .interior {
    background: linear-gradient(var(--c-line) 0 10px, transparent 10px),
      linear-gradient(color-mix(in oklab, var(--c-seat), var(--c-ink) 12%), var(--c-seat) 30%, color-mix(in oklab, var(--c-seat), var(--c-ink) 20%));
    block-size: 24%;
    position: absolute;
    inset: auto 0 0;
  }

  .straps {
    background: linear-gradient(color-mix(in oklab, var(--c-paper), var(--c-ink) 40%), color-mix(in oklab, var(--c-car-wall), var(--c-ink) 55%));
    block-size: 6px;
    position: absolute;
    inset: 9% 0 auto;
  }

  .straps i {
    transform-origin: 50% 0;
    background: linear-gradient(var(--c-line), var(--c-line)) 50% 0 / 2px calc(100% - 4vmin) no-repeat;
    block-size: 20vh;
    inline-size: 4vmin;
    animation: 3.2s ease-in-out infinite alternate sway;
    animation-delay: calc(var(--i) * -.4s);
    animation-delay: calc(sibling-index() * -.4s);
    position: absolute;
    inset-block-start: 6px;
    inset-inline-start: calc(var(--i) * 88% / var(--n, 8) + 1%);
  }

  .straps i:after {
    content: "";
    aspect-ratio: 1;
    border: .6vmin solid var(--c-line);
    border-radius: 50%;
    position: absolute;
    inset: auto 0 0;
  }

  @keyframes sway {
    from {
      rotate: -1.5deg;
    }

    to {
      rotate: 1.5deg;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .far, .near, .station, .straps i {
      animation: none;
    }

    .far {
      translate: -40px;
    }

    .near {
      translate: -170px;
    }

    .straps i {
      rotate: 0deg;
    }
  }
}

@layer utilities;