Shots in this scene
Build: A Loading Bar That Keeps Time
In one line
Write src/js/motion/loop.js, a small time-based tween helper, and use it for a "Loading the Volt" bar on the launch page. The bar lasts the same time at 30, 60 and 120 Hz and ends exactly when the page has loaded.
Before you start
You will learn
- Write a time-based tween helper that you will reuse for the rest of the course
- Add a loading bar to the launch page that tracks the real page load
- Know what the bar does at 30, 60 and 120 Hz, in a hidden tab, and with zero duration
You write code in
In your lumen-motion project: add src/js/motion/loop.js, and change index.html, components.css and main.js as marked Phase 1. Run npm run dev
Files you touch
- src/js/motion/loop.js
- index.html
- src/styles/components.css
- src/js/main.js
Think of it like this
The progress display on a lift tells you which floor you are at, not how many seconds the lift has been moving. Our bar works the same way. It shows how far the load has got, and nothing in it depends on how fast this particular screen draws.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- tween
- Short for "in-between": a function that moves a number from one value to another over a duration, calling you back on each frame.
- easing function
- A function that turns progress into shaped progress, such as fast at first and slow at the end. Phase 5 covers it.
- cancel
- Stop a running animation so it makes no further frames and calls nothing.
- role="progressbar"
- Tells assistive technology that an element shows how far something has got.
Step by step
01Add loop.js
Create src/js/motion/loop.js with the file under "Your project files". tween asks for a frame, remembers the first frame's timestamp, and on each frame works out progress = min(elapsed / duration, 1) and calls onUpdate with the interpolated value. A zero or negative duration finishes on the first frame. cancel() stops it.
02Add the bar to index.html and components.css
Paste the Phase 1 block under the Reserve button in index.html and the Phase 1 block at the end of components.css. The fill starts at scaleX(0) with transform-origin: left, so it grows from its left edge.
03Wire it in main.js
Add the import at the top of main.js and the Phase 1 block at the bottom. Run npm run dev and reload. The bar fills quickly: a local page loads at once, so you mostly see the 300 ms finish. In DevTools' Network tab, switch throttling to a slow profile and reload to watch it creep.
04Test the bar's time
"The launch page with its loading bar" below replaces the load event with a 1.5-second timer so you can watch every stage. Pause it, step through, and click the page's elements: nothing else on the page is animated.
05Run the edge cases
"Tween edges" prints what tween does when it is cancelled before its first frame, when the duration is 0, and when it ends. The ending value is exactly 100, not 99.97.
Try it yourself
- 1
Watch the bar at the slow stage
In "The launch page with its loading bar" press Replay, then slow the page to 0.1×. The 1.5-second wait takes 15 seconds, and the 300 ms finish takes 3. The bar is not a CSS animation, so the timeline shows no track for it. Its frames still appear in the frame strip.
- 2
Make the load arrive sooner
Change
setTimeout(resolve, 1500)tosetTimeout(resolve, 300)and replay. The creep is cancelled early, and the finish starts from wherever the bar had got. Becausefromisshown, the finish never jumps backward. - 3
Change the easing and read the end
In "Tween edges" change
easeOutCubicto(t) => t * 2. The last line now printsended exactly on 200. The tween always ends onfrom + (to - from) * easing(1), so an easing must return 1 at progress 1 if the end is to be thetoyou asked for.
Code & diagrams
The project's files inlined into one page: the body of index.html, both stylesheets and main.js with loop.js above it (local imports cannot run here). A 1.5-second timer stands in for the `load` event. Press Replay to see the bar again.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Plain console output: a cancelled tween, a zero-duration tween and a tween that ends. `loop.js` is inlined at the top.
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
cancelled before the first frame
zero duration, value: 100
zero duration: done
50 ms tween ended exactly on 100Your 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.
// loop.js: time-based animation helpers (Phase 1). Phase 8 adds more to this file.
export const linear = (t) => t;
export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
/**
* tween: moves a number from `from` to `to` over `duration` ms and calls onUpdate(value) on every frame.
* It reads the clock instead of counting frames, so it lasts the same time at 30, 60 and 120 Hz, and after
* a hidden tab it is finished. Returns { cancel() }.
*/
export function tween({ from, to, duration, easing = linear, onUpdate, onDone }) {
let start;
let id = 0;
let live = true;
function frame(now) {
if (!live) return;
start ??= now; // the first frame is progress 0
const progress = duration > 0 ? Math.min((now - start) / duration, 1) : 1; // a zero duration finishes at once
onUpdate(from + (to - from) * easing(progress));
if (progress < 1) id = requestAnimationFrame(frame);
else { live = false; onDone?.(); }
}
id = requestAnimationFrame(frame);
return { cancel() { live = false; cancelAnimationFrame(id); } };
}
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
Start the finish tween from zero
In "The launch page with its loading bar" change from: shown, to from: 0, and press Replay.
Myth vs fact
Myth
A loading bar should always run for a fixed time so it looks smooth.
Fact
A bar that ignores the real load is a promise it cannot keep. This one creeps while it waits and finishes when the load does, so the bar and the page agree.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
drawsees a new value every frame, butaria-valuenowis written only when the tens value (Math.floor(p * 10) * 10) changes: about ten writes, not sixty a second. A test such aspercent % 10 === 0would be wrong: frames jump over most round numbers, so it would skip some tens. For a screen reader, frequent changes are noise. - ▸
The creep stops at 85% because stopping at 100% would claim the load is done. If the load never finishes, the bar stays at 85% and the label still says "Loading the Volt…": honest.
Remember this
- 1
The problem this solves: the story chapter. Lena tested the first draft of the loading bar, which counted frames. It ended in one second on her 60 Hz test laptop, in half a second on her 120 Hz iPhone, and in two seconds in power-saving mode. The designers had agreed one timing. After this topic the bar lasts what it should, on every screen.
- 2
What it adds.
src/js/motion/loop.jsexportstween({ from, to, duration, easing, onUpdate, onDone }), which returns{ cancel() }, plus two easing functions.main.jsuses it for a bar in the hero. While the page loads, the bar creeps toward 85% (we cannot know how long the load takes), then, when theloadevent fires, it cancels the creep and finishes from wherever it is in 300 ms. Then the label says "The Volt is ready" and the bar goes away. - 3
How it plugs in. The bar is new HTML inside the hero (
#volt-loading) and a few lines of CSS, in blocks marked Phase 1. It moves withtransform: scaleX(...)on the fill, so the browser only composites it (Topic 1.4). The bar is arole="progressbar"and itsaria-valuenowis updated every 10%, not on every frame, so a screen reader is not flooded. Phase 0's code is otherwise unchanged. - 4
Why the tween does not clamp the time step. A tween's value depends only on elapsed time. After a hidden tab the first frame has a large
elapsed, progress becomes 1 and the bar finishes (Topic 1.3). The clamp is for step-by-step loops, which Phase 9 uses for springs. - 5
What the launch site can do now that it could not before: show honest loading progress that is the same length on every screen, and a reusable
tweenfor any later number-to-number animation (a counter, a scroll position, a progress ring). What is still missing: feedback on buttons (Phase 3), the toast (Phase 4), easing choices (Phase 5), the performance check of what we animate (Phase 6) and the reduced-motion switch (Phase 7). The bar is information, so it keeps moving under reduced motion; Phase 7 reviews that choice.
Explain it without notes
Why does tween read now from requestAnimationFrame instead of counting frames?
What happens in tween if duration is 0, and why is it handled separately?
Practice
Add an optional delay (ms) to tween: nothing happens, not even onUpdate, until the delay has passed. Show the changed lines.
The bar is on a page the user may leave open in a background tab. What happens to the bar when they return, and is that what you want?
Done when you can
Your project has loop.js and the Phase 1 blocks in three other files
The launch page shows a loading bar that finishes when the page loads
You can say why the bar lasts the same time at 30, 60 and 120 Hz
You can say what the bar does with a zero duration, a cancel and a hidden tab