Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 6.7Intermediate ~18 min · shot 7 of 7

Build: Fix the Janky Hero

In one line

Kofi's hero drifted with left and pulsed a box-shadow, so every frame laid out and repainted the page. You make the drift a transform, the glow an opacity pulse of a pseudo-element, pause both off screen, and write the measured before and after numbers in the README.

0/7 · 0%

Before you start

You will learn

  • Change the hero's drift and glow to compositor-only animations
  • Pause them when the hero is off screen, and calm them for reduced motion
  • Record the before and after numbers from a trace in README.md

You write code in

In your lumen-motion project (npm run dev): change index.html, src/styles/components.css and src/js/main.js, and add README.md

Files you touch

  • index.html
  • src/styles/components.css
  • src/js/main.js
  • README.md

Think of it like this

A shop window lit by a hundred bulbs that a clerk swaps by hand every second. Replace them with one lamp on a dimmer and the window looks the same, with no clerk.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

trace
A recording of every Layout and Paint event the browser did, with times.
pseudo-element
A box the browser makes from CSS (::before) that is not in the HTML.
frame budget
The time one frame has: 16.7 ms at 60 Hz.
baseline
The saved numbers from before a change.

Step by step

01Wrap the drawing

In index.html, put the Volt <svg> inside <div class="hero-art">. The wrapper is the box the glow is drawn behind.

02Add the Phase 6 CSS block

Add the block from components.css: .hero-art::before holds the finished shadow with opacity: 0, and glow fades it to 1. .volt drifts with transform.

03Pause it when nobody sees it

Add the observer block to main.js. Scroll the page: when the hero leaves the view, is-offscreen pauses both animations. Scrolling back resumes them.

04Measure and write it down

Record a trace of the old and new page (DevTools Performance, or a Playwright trace as Hectal did), count Layout and Paint, and put the numbers in README.md as the notes file shows.

Try it yourself

  1. 1

    Compare the tracks

    In "The hero, before and after" press Before, pause and click each track: drift-before is red with left, glow-before is amber with box-shadow. Press After: both tracks are green.

  2. 2

    Block the thread

    In your own project's console, run (() => { const t = performance.now(); while (performance.now() - t < 500); })() with each version of the hero. The old hero stops for 500 ms, and the new one keeps moving (25 to 27 frames drawn in my recording).

  3. 3

    Switch on Reduced motion

    In "The hero, before and after" switch the timeline's Reduced motion on. After has no animations (the drift stops and the glow sits at 60% opacity). Before keeps moving, because the old hero ignored the setting.

Code & diagrams

The hero, before and after

Switch between the two. Read the tracks: before is red (`left`) and amber (`box-shadow`), after is green and green (the glow's track targets the pseudo-element).

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

after: drift, glow
before: drift-before, glow-before
after: drift, glow

Your project files

Your lumen-motion project after this topic. Click a file to see it in full.

index.htmlhtml

The whole file after this topic. If yours differs, you can copy this one and carry on.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lumen Volt · Lumen Bikes</title>
    <!-- Phase 2: motion tokens (load first, so later files can use them) -->
    <link rel="stylesheet" href="/src/styles/tokens.css" />
    <!-- end Phase 2 -->
    <link rel="stylesheet" href="/src/styles/base.css" />
    <link rel="stylesheet" href="/src/styles/components.css" />
  </head>
  <body>
    <header class="site-header">
      <a class="logo" id="logo" href="/">lumen</a>
      <nav aria-label="Main"><a href="#accessories">Accessories</a> <a href="#gallery">Gallery</a> <a href="#specs">Specs</a></nav>
      <button id="cart-button" class="cart-button" aria-label="Cart, 0 items">Cart <span id="cart-count">0</span></button>
    </header>
    <main>
      <section id="hero" class="hero">
        <div>
          <h1>The Lumen Volt</h1>
          <p class="hero-copy">Our first e-bike. 80 km on one charge, built for Ardenvale's hills.</p>
          <p class="price">€2,490</p>
          <button id="reserve" class="button primary">Reserve yours</button>
          <!-- Phase 1: loading the Volt -->
          <div class="loading-wrap">
            <div class="loading" id="volt-loading" role="progressbar" aria-label="Loading the Volt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="loading-fill"></div></div>
            <p class="loading-label" id="volt-loading-label">Loading the Volt…</p>
          </div>
          <!-- end Phase 1 -->
        </div>
        <!-- Phase 6: a wrapper, so the glow can be a pseudo-element behind the drawing -->
        <div class="hero-art">
        <svg id="volt-hero" class="volt" viewBox="0 0 240 140" role="img" aria-label="The Lumen Volt e-bike">
          <g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="52" cy="98" r="34" />
            <circle cx="188" cy="98" r="34" />
            <path d="M52 98 L96 98 L132 50 L84 50 Z M96 98 L72 38 M132 50 L188 98 M126 34 L140 34 M132 50 L130 34" />
            <path d="M64 38 L82 38" />
          </g>
          <rect x="98" y="58" width="26" height="14" rx="4" fill="#2a9df4" />
        </svg>
        </div>
        <!-- end Phase 6 -->
      </section>
      <section id="accessories">
        <h2>Accessories</h2>
        <div class="grid">
          <article class="card"><h3>Lock</h3><p>€39</p><button class="add" data-product="lock">Add</button></article>
          <article class="card"><h3>Lights</h3><p>€29</p><button class="add" data-product="lights">Add</button></article>
          <article class="card"><h3>Rack</h3><p>€45</p><button class="add" data-product="rack">Add</button></article>
          <article class="card"><h3>Bell</h3><p>€9</p><button class="add" data-product="bell">Add</button></article>
          <article class="card"><h3>Basket</h3><p>€35</p><button class="add" data-product="basket">Add</button></article>
          <article class="card"><h3>Helmet</h3><p>€59</p><button class="add" data-product="helmet">Add</button></article>
        </div>
      </section>
      <section id="gallery">
        <h2>Gallery</h2>
        <div class="filters" role="group" aria-label="Show colour">
          <button class="filter" data-color="all" aria-pressed="true">All</button>
          <button class="filter" data-color="orange" aria-pressed="false">Orange</button>
          <button class="filter" data-color="blue" aria-pressed="false">Blue</button>
          <button class="filter" data-color="ink" aria-pressed="false">Ink</button>
        </div>
        <div class="photos">
          <figure class="photo" data-color="orange"><div class="swatch"></div><figcaption>Volt in orange, side</figcaption></figure>
          <figure class="photo" data-color="blue"><div class="swatch"></div><figcaption>Volt in blue, side</figcaption></figure>
          <figure class="photo" data-color="ink"><div class="swatch"></div><figcaption>Volt in ink, side</figcaption></figure>
          <figure class="photo" data-color="orange"><div class="swatch"></div><figcaption>Orange, at the harbour</figcaption></figure>
          <figure class="photo" data-color="blue"><div class="swatch"></div><figcaption>Blue, in the old town</figcaption></figure>
          <figure class="photo" data-color="ink"><div class="swatch"></div><figcaption>Ink, at night</figcaption></figure>
        </div>
      </section>
      <section id="specs">
        <h2>Specs</h2>
        <details class="spec"><summary>Battery</summary><p>500 Wh, removable, 80 km range, full in 4.5 hours.</p></details>
        <details class="spec"><summary>Motor</summary><p>250 W rear-hub motor, assists up to 25 km/h.</p></details>
        <details class="spec"><summary>Frame</summary><p>Aluminium, three sizes, 21 kg with the battery.</p></details>
      </section>
    </main>
    <footer class="site-footer"><p>Lumen Bikes · Ardenvale</p></footer>
    <!-- Phase 4: where toasts appear (a live region: screen readers read what is added to it) -->
    <div id="toasts" role="status" aria-live="polite"></div>
    <!-- end Phase 4 -->
    <script type="module" src="/src/js/main.js"></script>
  </body>
</html>

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Animate the shadow again

In "The hero, before and after" (press After), delete opacity: 0; from .hero-art::before and change the keyframes to @keyframes glow { from { box-shadow: 0 0 0 0 rgb(255 107 44 / 0); } }.

terminal
$ Read the glow's track
── what you'll see ──
`div.hero-art::before [glow]`, cost: paint (amber). The drift stays composite.
The glow animates the shadow itself, so it repaints every frame, as in the old hero.

Myth vs fact

Myth

Fixing performance means making the animation less pretty.

Fact

The hero looks the same. Only the property that carries the effect changed.

Remember this

  1. 1

    The problem this solves (Lena's problem): the hero looked lovely and made her phone stutter, and scrolling lagged under it. Ama needs the glow and the drift, and Lena needs her 16.7 ms frames. This build keeps both.

  2. 2

    What it adds. index.html wraps the drawing in div.hero-art. components.css gets a Phase 6 block: the drift is transform: translateX(-12px → 12px), and the glow is the finished box-shadow on .hero-art::before whose opacity pulses. main.js adds an IntersectionObserver that toggles is-offscreen, which pauses both. A reduced-motion block stops the drift and shows the glow still. The three files below are complete: they also keep the earlier blocks (Phase 1 loading bar, Phase 2 tokens link, Phase 3 feedback, Phase 4 toasts, Phase 5 reveal), so copy whole files only if you have not changed them.

  3. 3

    Measured, before and after (headless Chromium 153, software rendering, the real page at 1280 × 720, two seconds of the hero running): before, 121 Layout events and 363 Paint events, about 13 to 15 ms of main-thread layout and 22 to 24 ms of paint. After, 0 and 0. When I blocked the main thread for 500 ms, the old hero drew 0 frames and the new one drew 25 to 27. A GPU phone will differ: record your own trace.

  4. 4

    What the launch site can do now that it could not before: it keeps its hero motion while scripts run, scrolls smoothly under it, and stops it when the hero is out of view or the user asks for reduced motion. What is still missing: the full site-wide reduced-motion policy and the motion toggle (Phase 7), and will-change is not needed here, because the browser promotes the two animated layers itself.

Explain it without notes

01

Why does the new hero keep moving when the main thread is busy?

02

Why does the glow use a pseudo-element?

Practice

01

Add a .hero-art:hover::before { opacity: 1; } rule so the glow stays on when hovered, without breaking the pause. Does it still cost nothing?

02

Where would you put the numbers of a new measurement?

Done when you can

  • Your hero's animations are green on the Motion Timeline

  • Scrolling away pauses them, and reduced motion calms them

  • The README holds measured before and after numbers

  • You can say what is still missing (Phase 7: the full motion policy)