Vertical Drive how it’s made

The idea

A race circuit seen from above, laid over faint contour lines. A lime car drives three laps, slipping past three grey rivals one by one, while the HUD ticks through LAP 1/3 → 3/3, P4 → P1 and a race clock. The track is portrait-shaped, so on a phone it fills the screen. No JavaScript in the scene; the playback tab on the edge of the window is the throttle, and with loop off the race runs once and holds at the flag.

Techniques

offset-path car on a keyframe clock

Each car is an <i> drawn nose-right, because offset-rotate: auto points the element's +x axis along the direction of travel. The track is a closed path, so offset-distance past 100% wraps into the next lap. One keyframe from 0% to 300% is three laps:

@keyframes drive { from { offset-distance: var(--s, 0%); } to { offset-distance: var(--e); } } /* closed path: past 100% it wraps into the next lap */

Rivals reuse the same keyframes. Each starts a little ahead (--s) and covers a little less distance (--e), so the player passes them in turn:

.rival { /* each starts ahead and runs a little slower; you pass them at 27%, 43% and 64% of the race */
  --body: var(--rival);
  --s: calc(var(--i) * var(--i) * 1.5% + 2.5%);
  --e: calc(var(--s) + 290% - var(--i) * 5%);
  transform: translateY(1.8cqi);
}

transform applies after the offset transform, so translateY here is relative to the car's heading: rivals keep to one side of the racing line and you to the other, so a pass never overlaps. Motion path is widely available (2025-03-12), and offset-distance is composited like any transform.

shape() motion path that scales with the board

path() only takes pixels, so a path() route can't grow with the viewport. shape() takes percentages of the reference box, which for a positioned car is its containing block (the board). The board keeps the SVG's 100 × 160 aspect, so the SVG's x maps to x% and y maps to y / 160 %. The drawn track and the motion path line up at any size:

@supports (offset-path: shape(from 0 0, line to 1px 1px)) {
  .board { inline-size: min(92vw, 52.5dvh); }
  /* the same path in percentages of the board (y / 160), so it scales with it */
  .car {
    offset-path: shape(from 82% 81.25%, line to 82% 21.25%,
      curve to 56% 20% with 82% 8.75% / 56% 8.75%,

shape() is newly available (2026-02-24). Without it, the board stays a fixed 250 × 400 px and the cars use the same route as path() at 2.5 px per unit. That is still readable on a 320 px phone.

One keyframe script on one clock

As in Liftoff, the cars and the HUD share one 30s --loop clock, so the whole race is keyframe percentages and the Race length control is a single custom property. The page never scrolls.

@property integer race clock split into counter()

A registered <integer> interpolates and rounds, so animating --cs from 0 to 26400 is a centisecond clock. round() and mod() (newly available 2024-05-17) split it into minutes, seconds and hundredths inside counter-reset:

counter-reset: lap var(--lap) pos var(--pos)
  min calc(round(down, var(--cs) / 6000)) sec calc(mod(round(down, var(--cs) / 100), 60)) cs calc(mod(var(--cs), 100));

Lap and position are integers too. Their keyframes hold a value, then step 0.2% later, so rounding can't flip them early. Without round()/mod() the clock reads 0:00.00, and lap and position still work.

SVG <use> layers from one path

The track is one <path>, used three times: a 10-unit lime stroke, a 9-unit tarmac stroke on top (leaving a thin lime edge on each side) and a dashed centreline. The kerbs can never drift from the tarmac.

Performance notes

rawgzipbrotlibudgetok
7,332 B2,966 B2,562 B14,336 Byes

The cars animate only offset-distance, which is a transform. The contour background and SVG track paint once. The HUD integers change text, so each step costs a small layout and paint in the HUD box (paint-ok). Each car has a small static drop-shadow that moves with it. Frame times and layer counts are not measured yet.

Accessibility

Reuse

Uses lib/stage. Two patterns are candidates for extraction: the "SVG path, then shape() in percentages of an aspect-locked box" route, and the round()/mod() clock.

<!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>Vertical Drive</title>
<!--css-->
<main class="stage" aria-label="A top-down race circuit over a contour map. A lime race car drives three laps, passing three rivals, while a lap counter, position and race clock count up.">
<div class="board" aria-hidden="true">
<svg viewBox="0 0 100 160">
<path id="t" d="M82 130L82 34C82 14 56 14 56 32C56 46 40 50 30 42C18 32 14 46 18 62C20 80 40 84 42 100C44 116 18 112 18 132C18 150 40 152 52 146C64 140 82 150 82 130Z"/>
<use href="#t" class="kerb"/><use href="#t" class="tarmac"/><use href="#t" class="centre"/>
<path class="grid" d="M77.5 120h9"/>
</svg>
<i class="car rival" style="--i:1"></i><i class="car rival" style="--i:2"></i><i class="car rival" style="--i:3"></i><i class="car you"></i>
</div>
<p class="hud" aria-hidden="true"></p>
</main>
@import "lib/stage";

/* typed integers interpolate (and round), so an animation can count */
@property --lap { syntax: "<integer>"; inherits: false; initial-value: 1; }
@property --pos { syntax: "<integer>"; inherits: false; initial-value: 4; }
@property --cs { syntax: "<integer>"; inherits: false; initial-value: 0; } /* race clock, centiseconds */

@layer scene {
  .stage {
    /* three laps, on the clock; the playback tab sets speed and whether it repeats */
    --loop: 30s;
    --lime: #d2ff00;
    --night: #111112;
    --bg: light-dark(#ecece4, var(--night));
    --contour: light-dark(#1111121a, #f2f2ee14);
    --tarmac: light-dark(#c9c9c0, #26262a);
    --paint: light-dark(var(--night), #f2f2ee);
    --rival: light-dark(#55555c, #b9b9c0);
    display: grid;
    place-items: center;
    /* topographic contours: two offset ring fields, painted once */
    background:
      repeating-radial-gradient(circle at 18% 78%, #0000 0 5vmin, var(--contour) 0 calc(5vmin + 1px)),
      repeating-radial-gradient(ellipse at 88% 12%, #0000 0 7vmin, var(--contour) 0 calc(7vmin + 1px)),
      var(--bg);
  }

  /* The board keeps the SVG's 100 x 160 aspect, so viewBox units and percentages map 1:1 */
  .board {
    position: relative;
    container-type: inline-size;
    inline-size: 250px;
    aspect-ratio: 5 / 8;
  }
  svg { display: block; inline-size: 100%; block-size: 100%; overflow: visible; }
  svg path, svg use { fill: none; stroke-linejoin: round; }
  .kerb { stroke: light-dark(#9fbf00, var(--lime)); stroke-width: 10; } /* shows as a thin lime edge either side of the tarmac */
  .tarmac { stroke: var(--tarmac); stroke-width: 9; }
  .centre { stroke: var(--paint); stroke-width: 0.35; stroke-dasharray: 2 3; opacity: 0.5; }
  .grid { stroke: var(--paint); stroke-width: 2; stroke-dasharray: 1.5; }

  /* Cars ride the track centreline. The fallback path() is the SVG path at 2.5px per unit (fixed 250px board) */
  .car {
    position: absolute;
    inset: 0 auto auto 0;
    inline-size: 8cqi;  /* drawn nose-right: offset-rotate: auto aligns +x with the direction of travel */
    block-size: 4.2cqi;
    offset-path: path("M205 325L205 85C205 35 140 35 140 80C140 115 100 125 75 105C45 80 35 115 45 155C50 200 100 210 105 250C110 290 45 280 45 330C45 375 100 380 130 365C160 350 205 375 205 325Z");
    offset-rotate: auto;
    border-radius: 1cqi;
    background:
      radial-gradient(circle at 46% 50%, var(--night) 0 0.9cqi, #0000 0),                          /* cockpit */
      linear-gradient(90deg, var(--body) 0 7%, #0000 0 90%, var(--body) 0) 0 50% / 100% 100% no-repeat, /* wings */
      linear-gradient(#0000 24%, var(--body) 0 76%, #0000 0),                                    /* body */
      linear-gradient(90deg, #0000 12%, var(--night) 0 27%, #0000 0 64%, var(--night) 0 79%, #0000 0); /* tyres */
    filter: drop-shadow(0 0.4cqi 0.5cqi #0007);
    animation: drive var(--loop) linear infinite both;
  }
  .you {
    --body: var(--lime);
    --e: 300%;
    transform: translateY(-1.8cqi); /* applied after the offset transform, so it is relative to the heading: the inside line */
  }
  .rival { /* each starts ahead and runs a little slower; you pass them at 27%, 43% and 64% of the race */
    --body: var(--rival);
    --s: calc(var(--i) * var(--i) * 1.5% + 2.5%);
    --e: calc(var(--s) + (290% - var(--i) * 5%) * var(--pace, 1));
    transform: translateY(1.8cqi);
  }
  @keyframes drive { from { offset-distance: var(--s, 0%); } to { offset-distance: var(--e); } } /* closed path: past 100% it wraps into the next lap */

  @supports (offset-path: shape(from 0 0, line to 1px 1px)) {
    .board { inline-size: min(92vw, 52.5dvh); }
    /* the same path in percentages of the board (y / 160), so it scales with it */
    .car {
      offset-path: shape(from 82% 81.25%, line to 82% 21.25%,
        curve to 56% 20% with 82% 8.75% / 56% 8.75%,
        curve to 30% 26.25% with 56% 28.75% / 40% 31.25%,
        curve to 18% 38.75% with 18% 20% / 14% 28.75%,
        curve to 42% 62.5% with 20% 50% / 40% 52.5%,
        curve to 18% 82.5% with 44% 72.5% / 18% 70%,
        curve to 52% 91.25% with 18% 93.75% / 40% 95%,
        curve to 82% 81.25% with 64% 87.5% / 82% 93.75%, close);
    }
  }

  .hud {
    position: absolute;
    margin: 0;
    font: 600 clamp(0.8rem, 2.4vmin, 1.25rem)/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
    letter-spacing: 0.08em;
    inset: 3vmin auto auto 3vmin;
    padding: 0.4em 0.8em;
    border-radius: 0.5em;
    background: #111112e6;
    color: var(--lime); /* ~17:1 on the backing in both schemes */
    counter-reset: lap var(--lap) pos var(--pos)
      min calc(round(down, var(--cs) / 6000)) sec calc(mod(round(down, var(--cs) / 100), 60)) cs calc(mod(var(--cs), 100));
    animation: timing var(--loop) linear infinite both; /* paint-ok: counter text changes; tiny box */
  }
  .hud::before { content: "LAP " counter(lap) "/3  P" counter(pos); display: block; white-space: pre; }
  .hud::after { content: counter(min) ":" counter(sec, decimal-leading-zero) "." counter(cs, decimal-leading-zero); display: block; }
  @keyframes timing {
    0% { --cs: 0; --lap: 1; --pos: 4; }
    26.6% { --pos: 4; } 26.8% { --pos: 3; }
    33.3% { --lap: 1; } 33.4% { --lap: 2; }
    42.4% { --pos: 3; } 42.6% { --pos: 2; }
    63.9% { --pos: 2; } 64.1% { --pos: 1; }
    66.6% { --lap: 2; } 66.7% { --lap: 3; }
    100% { --cs: 26400; --lap: 3; --pos: 1; }
  }
  @media (prefers-reduced-motion: reduce) {
    .stage *, .stage *::before, .stage *::after { animation: none !important; }
    /* still frame: last lap, you lead out of the final corner with the pack behind */
    .you { offset-distance: 97%; }
    .rival { offset-distance: calc(94% - var(--i) * 3%); }
    .hud { --lap: 3; --pos: 1; --cs: 25611; }
  }
}

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 {
    --loop: 30s;
    --lime: #d2ff00;
    --night: #111112;
    --bg: light-dark(#ecece4, var(--night));
    --contour: light-dark(#1111121a, #f2f2ee14);
    --tarmac: light-dark(#c9c9c0, #26262a);
    --paint: light-dark(var(--night), #f2f2ee);
    --rival: light-dark(#55555c, #b9b9c0);
    background: repeating-radial-gradient(circle at 18% 78%, #0000 0 5vmin, var(--contour) 0 calc(5vmin + 1px)),
      repeating-radial-gradient(ellipse at 88% 12%, #0000 0 7vmin, var(--contour) 0 calc(7vmin + 1px)),
      var(--bg);
    place-items: center;
    display: grid;
  }

  .board {
    aspect-ratio: 5 / 8;
    inline-size: 250px;
    position: relative;
    container-type: inline-size;
  }

  svg {
    block-size: 100%;
    inline-size: 100%;
    display: block;
    overflow: visible;
  }

  svg path, svg use {
    fill: none;
    stroke-linejoin: round;
  }

  .kerb {
    stroke: light-dark(#9fbf00, var(--lime));
    stroke-width: 10px;
  }

  .tarmac {
    stroke: var(--tarmac);
    stroke-width: 9px;
  }

  .centre {
    stroke: var(--paint);
    stroke-width: .35px;
    stroke-dasharray: 2 3;
    opacity: .5;
  }

  .grid {
    stroke: var(--paint);
    stroke-width: 2px;
    stroke-dasharray: 1.5;
  }

  .car {
    offset-path: path("M205 325L205 85C205 35 140 35 140 80C140 115 100 125 75 105C45 80 35 115 45 155C50 200 100 210 105 250C110 290 45 280 45 330C45 375 100 380 130 365C160 350 205 375 205 325Z");
    offset-rotate: auto;
    background: radial-gradient(circle at 46% 50%, var(--night) 0 .9cqi, #0000 0),                          /* cockpit */
      linear-gradient(90deg, var(--body) 0 7%, #0000 0 90%, var(--body) 0) 0 50% / 100% 100% no-repeat, /* wings */
      linear-gradient(#0000 24%, var(--body) 0 76%, #0000 0),                                    /* body */
      linear-gradient(90deg, #0000 12%, var(--night) 0 27%, #0000 0 64%, var(--night) 0 79%, #0000 0);
    filter: drop-shadow(0 .4cqi .5cqi #0007);
    block-size: 4.2cqi;
    inline-size: 8cqi;
    animation: drive var(--loop) linear infinite both;
    border-radius: 1cqi;
    position: absolute;
    inset: 0 auto auto 0;
  }

  .you {
    --body: var(--lime);
    --e: 300%;
    transform: translateY(-1.8cqi);
  }

  .rival {
    --body: var(--rival);
    --s: calc(var(--i) * var(--i) * 1.5% + 2.5%);
    --e: calc(var(--s) + (290% - var(--i) * 5%) * var(--pace, 1));
    transform: translateY(1.8cqi);
  }

  @keyframes drive {
    from {
      offset-distance: var(--s, 0%);
    }

    to {
      offset-distance: var(--e);
    }
  }

  @supports (offset-path: shape(from 0 0, line to 1px 1px)) {
    .board {
      inline-size: min(92vw, 52.5dvh);
    }

    .car {
      offset-path: shape(from 82% 81.25%, line to 82% 21.25%,
        curve to 56% 20% with 82% 8.75% / 56% 8.75%,
        curve to 30% 26.25% with 56% 28.75% / 40% 31.25%,
        curve to 18% 38.75% with 18% 20% / 14% 28.75%,
        curve to 42% 62.5% with 20% 50% / 40% 52.5%,
        curve to 18% 82.5% with 44% 72.5% / 18% 70%,
        curve to 52% 91.25% with 18% 93.75% / 40% 95%,
        curve to 82% 81.25% with 64% 87.5% / 82% 93.75%, close);
    }
  }

  .hud {
    letter-spacing: .08em;
    color: var(--lime);
    counter-reset: lap var(--lap) pos var(--pos)
      min calc(round(down, var(--cs) / 6000)) sec calc(mod(round(down, var(--cs) / 100), 60)) cs calc(mod(var(--cs), 100));
    animation: timing var(--loop) linear infinite both;
    background: #111112e6;
    border-radius: .5em;
    margin: 0;
    padding: .4em .8em;
    font: 600 clamp(.8rem, 2.4vmin, 1.25rem) / 1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
    position: absolute;
    inset: 3vmin auto auto 3vmin;
  }

  .hud:before {
    content: "LAP " counter(lap) "/3  P" counter(pos);
    white-space: pre;
    display: block;
  }

  .hud:after {
    content: counter(min) ":" counter(sec, decimal-leading-zero) "." counter(cs, decimal-leading-zero);
    display: block;
  }

  @keyframes timing {
    0% {
      --cs: 0;
      --lap: 1;
      --pos: 4;
    }

    26.6% {
      --pos: 4;
    }

    26.8% {
      --pos: 3;
    }

    33.3% {
      --lap: 1;
    }

    33.4% {
      --lap: 2;
    }

    42.4% {
      --pos: 3;
    }

    42.6% {
      --pos: 2;
    }

    63.9% {
      --pos: 2;
    }

    64.1% {
      --pos: 1;
    }

    66.6% {
      --lap: 2;
    }

    66.7% {
      --lap: 3;
    }

    100% {
      --cs: 26400;
      --lap: 3;
      --pos: 1;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .stage *, .stage :before, .stage :after {
      animation: none !important;
    }

    .you {
      offset-distance: 97%;
    }

    .rival {
      offset-distance: calc(94% - var(--i) * 3%);
    }

    .hud {
      --lap: 3;
      --pos: 1;
      --cs: 25611;
    }
  }
}

@layer utilities;

@property --lap {
  syntax: "<integer>";
  inherits: false;
  initial-value: 1;
}

@property --pos {
  syntax: "<integer>";
  inherits: false;
  initial-value: 4;
}

@property --cs {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}