Full Send how it’s made

The idea

A race-day poster that builds itself. The outlines of FULL SEND draw themselves in, lime floods the letters from the bottom up, and colour sweeps across ALL GAS, NO BRAKES. All the while, a tilted lime ticker slides behind the word. No JavaScript and no web fonts.

Techniques

stroke-dashoffset draw-in on SVG text, shared through <use>

The word is two <text> lines in a <defs> group. A dash longer than any glyph's outline, offset by its own length, starts invisible. Animating the offset to 0 draws every glyph at once. The dash pattern restarts for each glyph, so the letters draw in together:

.line {
  fill: none;
  stroke: var(--ink);
  stroke-width: 0.9; /* only the outer half shows, see .knock */
  stroke-linejoin: round;
  stroke-dasharray: 220;
  stroke-dashoffset: 0;
  animation: inherit;
  animation-name: draw;
}

System fonts are often variable fonts with overlapping contours. On macOS the N's diagonal shows a stray stroke inside the letter. A second <use> filled with the background colour sits on top and hides the stroke's inner half, overlaps included:

/* a background-coloured fill over the stroke hides its inner half, including where system-font contours overlap (the N) */
.knock { fill: var(--bg); }

textLength with lengthAdjust="spacingAndGlyphs" pins each line to 96 units wide, so the poster lays out the same whatever system font renders it.

clip-path flood on a third <use>

The lime copy is clipped with inset(). On an SVG element with no CSS box, the reference box is the text's own bounds, so inset(100% 0 0) → inset(0) raises the lime from the baseline area up through the glyphs:

@keyframes flood { 0%, 35% { clip-path: inset(100% 0 0); } } /* paint-ok: clip on two short text runs */

background-clip: text colour sweep

The tagline's background is a hard two-tone gradient twice the line's width. Sliding it from 100% to 0 moves the colour edge across the letters:

@supports (background-clip: text) {
  .sub {
    background: linear-gradient(90deg, light-dark(var(--night), var(--lime)) 50%, var(--muted) 0) 0 0 / 200% 100%;
    background-clip: text;
    color: #0000;
  }

web-features lists background-clip: text as limited availability. Outside the @supports block the line is plain solid ink.

Finished-frame base styles, from-only keyframes

Every base style describes the finished poster (stroke-dashoffset: 0, clip-path: inset(0), background-position: 0 0). Keyframes only say where things start, as in @keyframes approach { from { scale: 0.9; } }. Reduced motion therefore needs no still-frame values at all: turning animation off shows the end state.

One alternating clock

A shared shorthand gives every stage child the same clock: a 9s loop that alternates forwards and back, so the poster draws and undraws instead of snapping back to empty.

.stage > * { animation: var(--loop) linear infinite var(--dir) both; }

With the playback tab's loop off, the scene finishes on a forwards pass and holds the finished poster.

Performance notes

rawgzipbrotlibudgetok
5,555 B2,276 B1,942 B14,336 Byes

The ticker and the word's scale are compositor-only. The stroke draw, the flood clip and the tagline sweep repaint, but only two short text runs and one line of text (all marked paint-ok). There are no filters and no blur. Frame times and layer counts are not measured yet.

A build gotcha: Lightning CSS turned font-stretch: condensed plus the font shorthand into an invalid font: 900 75% 40px, so the whole declaration was dropped. The scene sets the width with textLength instead.

Accessibility

Reuse

Uses lib/stage. Candidate snippets: the three-layer SVG text treatment (stroke, knockout, flood) and the finished-frame / from-only keyframe convention.

<!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>Full Send</title>
<!--css-->
<main class="stage" aria-label="The words FULL SEND in giant outlined letters. Their outlines draw in, lime floods them from the bottom up, colour sweeps across the line ALL GAS, NO BRAKES. and a tilted lime ticker runs past.">
<div class="band" aria-hidden="true"><span>NO LIFT · FLAT OUT · KEEP IT PINNED · NO LIFT · FLAT OUT · KEEP IT PINNED · </span></div>
<svg class="word" viewBox="0 0 100 84" aria-hidden="true">
<defs><g id="w"><text x="2" y="38" textLength="96" lengthAdjust="spacingAndGlyphs">FULL</text><text x="2" y="78" textLength="96" lengthAdjust="spacingAndGlyphs">SEND</text></g></defs>
<use href="#w" class="line"/><use href="#w" class="knock"/><use href="#w" class="flood"/>
</svg>
<p class="sub" aria-hidden="true">ALL GAS, NO BRAKES.</p>
</main>
@import "lib/stage";

@layer scene {
  .stage {
    /* Time loop that draws forwards then back; with the playback tab's loop off it draws once and holds.
       Base styles are the finished frame and keyframes only say `from`, so reduced motion just switches animation off. */
    --loop: 9s;
    --dir: alternate;
    --lime: #d2ff00;
    --night: #111112;
    --bg: light-dark(#ecece4, var(--night));
    --ink: light-dark(var(--night), #f2f2ee);
    --muted: light-dark(#11111233, #f2f2ee33);
    display: grid;
    place-content: center;
    justify-items: center;
    gap: 3vmin;
    background: var(--bg);
    color: var(--ink);
  }
  .stage > * { animation: var(--loop) linear infinite var(--dir) both; }

  .word {
    position: relative; /* paint after (above) the band */
    inline-size: min(94vw, 100dvh);
    overflow: visible;
    font: 900 40px system-ui, sans-serif; /* textLength sets the width, so no font-stretch needed */
    animation-name: approach;
  }
  @keyframes approach { from { scale: 0.9; } }
  .flood {
    fill: var(--lime);
    clip-path: inset(0); /* rises from the glyphs' bottom edge */
    animation: inherit;
    animation-name: flood;
  }
  @keyframes flood { 0%, 35% { clip-path: inset(100% 0 0); } } /* paint-ok: clip on two short text runs */
  .line {
    fill: none;
    stroke: var(--ink);
    stroke-width: 0.9; /* only the outer half shows, see .knock */
    stroke-linejoin: round;
    stroke-dasharray: 220;
    stroke-dashoffset: 0;
    animation: inherit;
    animation-name: draw;
  }
  /* a background-coloured fill over the stroke hides its inner half, including where system-font contours overlap (the N) */
  .knock { fill: var(--bg); }
  @keyframes draw { from { stroke-dashoffset: 220; } 50% { stroke-dashoffset: 0; } } /* paint-ok: stroke on two text runs */

  /* background-clip: text: a two-tone gradient twice the line's width slides under the letters */
  .sub {
    margin: 0;
    font: 800 clamp(1rem, 4.5vmin, 2.4rem)/1 system-ui, sans-serif;
    letter-spacing: 0.06em;
    color: var(--ink); /* without background-clip: text the line is simply solid */
    animation-name: sweep;
  }
  @supports (background-clip: text) {
    .sub {
      background: linear-gradient(90deg, light-dark(var(--night), var(--lime)) 50%, var(--muted) 0) 0 0 / 200% 100%;
      background-clip: text;
      color: #0000;
    }
    @keyframes sweep { 0%, 70% { background-position: 100% 0; } 95%, 100% { background-position: 0 0; } } /* paint-ok: one line of text */
  }

  .band {
    position: absolute;
    inset: 47% -10vw auto; /* crosses behind the word, between the lines */
    rotate: -5deg;
    overflow: clip;
    padding-block: 0.6em;
    background: var(--lime);
    color: var(--night);
    font: 800 clamp(0.8rem, 2.6vmin, 1.3rem)/1 ui-monospace, SFMono-Regular, Menlo, monospace;
    letter-spacing: 0.14em;
    white-space: nowrap;
    animation: none;
  }
  .band span {
    display: block;
    inline-size: max-content;
    translate: -50% 0; /* the text is written twice, so -50% lands on an identical frame */
    animation: ticker var(--loop) linear infinite var(--dir) both;
  }
  @keyframes ticker { from { translate: 0 0; } }

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

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: 9s;
    --dir: alternate;
    --lime: #d2ff00;
    --night: #111112;
    --bg: light-dark(#ecece4, var(--night));
    --ink: light-dark(var(--night), #f2f2ee);
    --muted: light-dark(#11111233, #f2f2ee33);
    background: var(--bg);
    color: var(--ink);
    place-content: center;
    justify-items: center;
    gap: 3vmin;
    display: grid;
  }

  .stage > * {
    animation: var(--loop) linear infinite var(--dir) both;
  }

  .word {
    inline-size: min(94vw, 100dvh);
    font: 900 40px system-ui, sans-serif;
    animation-name: approach;
    position: relative;
    overflow: visible;
  }

  @keyframes approach {
    from {
      scale: .9;
    }
  }

  .flood {
    fill: var(--lime);
    clip-path: inset(0);
    animation: inherit;
    animation-name: flood;
  }

  @keyframes flood {
    0%, 35% {
      clip-path: inset(100% 0 0);
    }
  }

  .line {
    fill: none;
    stroke: var(--ink);
    stroke-width: .9px;
    stroke-linejoin: round;
    stroke-dasharray: 220;
    stroke-dashoffset: 0;
    animation: inherit;
    animation-name: draw;
  }

  .knock {
    fill: var(--bg);
  }

  @keyframes draw {
    from {
      stroke-dashoffset: 220px;
    }

    50% {
      stroke-dashoffset: 0;
    }
  }

  .sub {
    letter-spacing: .06em;
    color: var(--ink);
    margin: 0;
    font: 800 clamp(1rem, 4.5vmin, 2.4rem) / 1 system-ui, sans-serif;
    animation-name: sweep;
  }

  @supports (background-clip: text) {
    .sub {
      background: linear-gradient(90deg, light-dark(var(--night), var(--lime)) 50%, var(--muted) 0) 0 0 / 200% 100%;
      color: #0000;
      background-clip: text;
    }

    @keyframes sweep {
      0%, 70% {
        background-position: 100% 0;
      }

      95%, 100% {
        background-position: 0 0;
      }
    }
  }

  .band {
    background: var(--lime);
    color: var(--night);
    letter-spacing: .14em;
    white-space: nowrap;
    padding-block: .6em;
    font: 800 clamp(.8rem, 2.6vmin, 1.3rem) / 1 ui-monospace, SFMono-Regular, Menlo, monospace;
    animation: none;
    position: absolute;
    inset: 47% -10vw auto;
    overflow: clip;
    rotate: -5deg;
  }

  .band span {
    inline-size: max-content;
    animation: ticker var(--loop) linear infinite var(--dir) both;
    display: block;
    translate: -50%;
  }

  @keyframes ticker {
    from {
      translate: 0;
    }
  }

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

@layer utilities;