Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 7.7Intermediate ~36 min · shot 7 of 7

Build: The Lumen Motion Toggle

In one line

Give the launch page one question every animation asks, motionAllowed(), and a footer switch (follow my device, reduced, full) that is saved, applied before first paint and survives blocked storage. The buttons, the toast and the loading bar now obey it.

0/7 · 0%

Before you start

You will learn

  • Add src/js/motion/prefers-reduced.js and src/styles/motion-safe.css
  • Add the head script and the footer toggle to index.html
  • Make the loading bar in main.js ask motionAllowed()

You write code in

In your lumen-motion project (npm run dev) and in the editor on this page: add two files, change index.html and src/js/main.js

Files you touch

  • src/styles/motion-safe.css
  • src/js/motion/prefers-reduced.js
  • index.html
  • src/js/main.js

Think of it like this

The master dimmer in a theatre. Each lamp has its own cue, but one control above all of them sets how bright anything may get. The motion switch is that control for every animation on the page.

Words you'll meet

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

motionAllowed()
The one function every script asks before it moves something. It returns false when the visitor chose reduced, or follows a device that asks for it.
onMotionChange(fn)
Registers a function called with the new answer whenever it changes. It returns a function that stops listening.
data-motion
The attribute on <html> that carries the visitor's choice (reduced or full) to CSS.
head script
A small ordinary script in <head> that runs before the first paint.

Step by step

01Add the module and the stylesheet

Create src/js/motion/prefers-reduced.js and src/styles/motion-safe.css with the contents below.

02Change index.html

Add the head script before the stylesheets, the motion-safe.css link after components.css, and the footer fieldset. The radio values must be system, reduced and full.

03Change main.js

Import the module, replace the loading bar block with the version that uses moveTo() and motionAllowed(), and call initMotionToggle() at the end.

04Try every combination

Choose each radio and press Add: the toast slides or fades. Reload: the choice is still there. Switch the timeline's Reduced motion on and repeat.

Try it yourself

  1. 1

    Switch the setting during the load

    Press Run, and within a second choose Reduced in the footer. The loading bar finishes its current move at once (onMotionChange calls jump()), and the label still says the same.

  2. 2

    Check each combination

    Press an Add button with each radio, then repeat with the timeline's Reduced motion on. The toast track is toast-in or toast-fade-in as listed above.

Code & diagrams

The launch page after Phase 7

The page after this phase: the body, the stylesheets and every script inlined (Phase 3's block is shortened; Phase 5's reveal and Phase 6's hero CSS are left out). The loading bar waits 1.5 s as a stand-in for the load event.

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.

Your project files

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

src/styles/motion-safe.csscss

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

/* motion-safe.css: the site's reduced-motion rules and the footer toggle (Phase 7).
   <html data-motion="..."> comes from the visitor's saved choice, set before first paint:
     no attribute = follow the device    reduced = calm, whatever the device says    full = normal, whatever it says
   The rule of this file: reduce, don't remove. Movement goes. Fades, colour changes and every state change stay. */

/* ── Phase 7: reduced on this site (the device may say no-preference) ── */
/* Same swaps that Phase 4 and Phase 3 make inside @media (prefers-reduced-motion: reduce). */
:root[data-motion="reduced"] .toast { animation-name: toast-fade-in; }
:root[data-motion="reduced"] .toast.leaving { animation-name: toast-fade-out; }
:root[data-motion="reduced"] :is(.button, .add, .filter, .cart-button, .primary):active { transform: none; }
:root[data-motion="reduced"] { scroll-behavior: auto; }
/* Phase 5's card reveal and Phase 6's hero loop: the same swaps as their own reduce blocks */
:root[data-motion="reduced"] .grid.reveal .card { opacity: 1; transform: none; }
:root[data-motion="reduced"] .grid.reveal.in .card { transition: none; }
:root[data-motion="reduced"] .volt, :root[data-motion="reduced"] .hero-art::before { animation: none; }
:root[data-motion="reduced"] .hero-art::before { opacity: 0.6; }

/* ── full on this site while the device says reduce: put the movement back ── */
@media (prefers-reduced-motion: reduce) {
  :root[data-motion="full"] .toast { animation-name: toast-in; }
  :root[data-motion="full"] .toast.leaving { animation-name: toast-out; }
  :root[data-motion="full"] :is(.button, .add, .filter, .cart-button, .primary):not(:disabled):active { transform: scale(0.97); }
  /* the reveal and the hero loop come back (longhands, so Phase 6's paused-while-off-screen rule still wins) */
  :root[data-motion="full"] .grid.reveal .card { opacity: 0; transform: translateY(14px); }
  :root[data-motion="full"] .grid.reveal.in .card { opacity: 1; transform: none; transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); transition-delay: calc(var(--i, 0) * var(--stagger-step)); }
  :root[data-motion="full"] .volt { animation-name: drift; animation-duration: 3s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; animation-direction: alternate; }
  :root[data-motion="full"] .hero-art::before { opacity: 0; animation-name: glow; animation-duration: 2s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; animation-direction: alternate; }
}

/* ── the footer toggle ── */
.motion-toggle { display: inline-flex; flex-wrap: wrap; justify-content: center; gap: 4px 14px; margin: 12px 0 0; padding: 4px 12px 8px; border: 1px solid #0002; border-radius: 10px; font-size: 13px; }
.motion-toggle legend { padding: 0 6px; font-weight: 600; }
.motion-toggle label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
.motion-toggle input:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
/* ── end Phase 7 ── */

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

Take out the site rule for the toast

In the page's CSS, delete the line :root[data-motion="reduced"] .toast { animation-name: toast-fade-in; }. Press Run, choose Reduced, press Add.

terminal
$ Press Add with Reduced chosen on a device with no preference
── what you'll see ──
The toast track is `toast-in`: it slides although the visitor chose Reduced. The switch looks like it works (the radio is selected, the attribute is set) and one animation ignores it.

Myth vs fact

Myth

Once the media query is in place, new animations are covered.

Fact

Only the ones whose CSS you wrote for it. Every new animation needs either a reduce rule, a data-motion twin, or a motionAllowed() check.

Remember this

  1. 1

    Why the site needs this now (Ravi's rule): the page already has button feedback, a toast and a loading bar, and Mei's next pieces are bigger. Before they arrive, every animation must answer to one place, so nothing added later can forget the visitor's setting.

  2. 2

    What it adds. prefers-reduced.js exports motionAllowed() and onMotionChange(fn) (plus setMotionChoice, motionChoice, initMotionToggle). motion-safe.css holds the data-motion rules, including twins for Phase 5's card reveal and Phase 6's hero loop. index.html gets a head script, the stylesheet link and the footer fieldset. main.js wires the toggle; the loading bar and the card reveal ask motionAllowed().

  3. 3

    How the pieces agree. Earlier CSS still uses the media query. motion-safe.css adds the same swaps for data-motion="reduced" and restores movement for full. Measured here, the toast animation on a normal device is toast-in (follow), toast-fade-in (reduced), toast-in (full); with the timeline's Reduced motion on: toast-fade-in, toast-fade-in, toast-in.

  4. 4

    The loading bar is JavaScript motion. With motion off it jumps to 85%, then to 100% when the page has loaded: the number, the label and aria-valuenow are unchanged. If the visitor switches motion off while it is moving, onMotionChange finishes the move at once.

  5. 5

    What the launch site can do now that it could not before: a visitor can choose calm or full motion whatever their device says, the choice is remembered, nothing flashes at load, and blocked storage breaks nothing. What is still missing: the cart bump (Phase 8), drawer (9), gallery (10), page transition (11) and scroll story (12) must each ask motionAllowed() or live under motion-safe.css. Phase 18 adds Playwright tests that run the page in reduced motion.

Explain it without notes

01

Why does the site need both the media query and data-motion rules?

02

What does the loading bar do when motion is switched off in the middle of a move?

Practice

01

Phase 8 adds a cart bump written in JavaScript. Write the guard it needs.

02

Add a fourth choice label, "Calm", that is a second name for reduced. Which file changes, and how?

Done when you can

  • You can list the four files this phase adds or changes

  • You can explain why the head script runs before the stylesheets

  • You can test the page in all six combinations of device and choice