Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 1.6Beginner ~29 min · shot 6 of 6

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.

0/6 · 0%

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. 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. 2

    Make the load arrive sooner

    Change setTimeout(resolve, 1500) to setTimeout(resolve, 300) and replay. The creep is cancelled early, and the finish starts from wherever the bar had got. Because from is shown, the finish never jumps backward.

  3. 3

    Change the easing and read the end

    In "Tween edges" change easeOutCubic to (t) => t * 2. The last line now prints ended exactly on 200. The tween always ends on from + (to - from) * easing(1), so an easing must return 1 at progress 1 if the end is to be the to you asked for.

Code & diagrams

The launch page with its loading bar

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.

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.
Tween edges

Plain console output: a cancelled tween, a zero-duration tween and a tween that ends. `loop.js` is inlined at the top.

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

cancelled before the first frame
zero duration, value: 100
zero duration: done
50 ms tween ended exactly on 100

Your project files

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

src/js/motion/loop.jsjs

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.

terminal
$ Replay, pause at the moment the load arrives, and step 3 frames
── what you'll see ──
The bar falls from about 85% to under half, then fills again.
Measured with the clock frozen: 0.85 just before the load, 0.44 a few frames later, 1 at the end. The creep is cancelled and the 300 ms tween restarts from 0.

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.

  • ▸

    draw sees a new value every frame, but aria-valuenow is written only when the tens value (Math.floor(p * 10) * 10) changes: about ten writes, not sixty a second. A test such as percent % 10 === 0 would 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. 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. 2

    What it adds. src/js/motion/loop.js exports tween({ from, to, duration, easing, onUpdate, onDone }), which returns { cancel() }, plus two easing functions. main.js uses 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 the load event 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. 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 with transform: scaleX(...) on the fill, so the browser only composites it (Topic 1.4). The bar is a role="progressbar" and its aria-valuenow is updated every 10%, not on every frame, so a screen reader is not flooded. Phase 0's code is otherwise unchanged.

  4. 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. 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 tween for 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

01

Why does tween read now from requestAnimationFrame instead of counting frames?

02

What happens in tween if duration is 0, and why is it handled separately?

Practice

01

Add an optional delay (ms) to tween: nothing happens, not even onUpdate, until the delay has passed. Show the changed lines.

02

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