Shots in this scene
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.
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
Compare the tracks
In "The hero, before and after" press Before, pause and click each track:
drift-beforeis red withleft,glow-beforeis amber withbox-shadow. Press After: both tracks are green. - 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
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
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).
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
after: drift, glow
before: drift-before, glow-before
after: drift, glowYour project files
Your lumen-motion project after this topic. Click a file to see it in full.
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); } }.
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
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
What it adds.
index.htmlwraps the drawing indiv.hero-art.components.cssgets a Phase 6 block: the drift istransform: translateX(-12px → 12px), and the glow is the finishedbox-shadowon.hero-art::beforewhoseopacitypulses.main.jsadds anIntersectionObserverthat togglesis-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
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
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-changeis not needed here, because the browser promotes the two animated layers itself.
Explain it without notes
Why does the new hero keep moving when the main thread is busy?
Why does the glow use a pseudo-element?
Practice
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?
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)