Shots in this scene
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.
Before you start
You will learn
- Add
src/js/motion/prefers-reduced.jsandsrc/styles/motion-safe.css - Add the head script and the footer toggle to
index.html - Make the loading bar in
main.jsaskmotionAllowed()
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 (reducedorfull) 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
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 (
onMotionChangecallsjump()), and the label still says the same. - 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-inortoast-fade-inas listed above.
Code & diagrams
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.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Your 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.
/* 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.
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
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
What it adds.
prefers-reduced.jsexportsmotionAllowed()andonMotionChange(fn)(plussetMotionChoice,motionChoice,initMotionToggle).motion-safe.cssholds thedata-motionrules, including twins for Phase 5's card reveal and Phase 6's hero loop.index.htmlgets a head script, the stylesheet link and the footer fieldset.main.jswires the toggle; the loading bar and the card reveal askmotionAllowed(). - 3
How the pieces agree. Earlier CSS still uses the media query.
motion-safe.cssadds the same swaps fordata-motion="reduced"and restores movement forfull. Measured here, the toast animation on a normal device istoast-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
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-valuenoware unchanged. If the visitor switches motion off while it is moving,onMotionChangefinishes the move at once. - 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 undermotion-safe.css. Phase 18 adds Playwright tests that run the page in reduced motion.
Explain it without notes
Why does the site need both the media query and data-motion rules?
What does the loading bar do when motion is switched off in the middle of a move?
Practice
Phase 8 adds a cart bump written in JavaScript. Write the guard it needs.
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