Shots in this scene
Build: The Swipe-to-Dismiss Cart Drawer
In one line
The cart button opens a drawer with a spring. You can drag it and throw it shut, turn it round mid-flight, close it with a button or Escape, and it fades for people who ask for less motion.
Before you start
You will learn
- Add
spring.jsanddrawer.js, then the drawer markup toindex.html - Add the drawer styles to
components.cssand the wiring tomain.js - Test: open, drag, flick, close with Escape, focus, reduced motion
You write code in
In your lumen-motion project (npm run dev): add src/js/motion/spring.js and src/js/components/drawer.js, and change index.html, src/styles/components.css and src/js/main.js.
Files you touch
- src/js/motion/spring.js
- src/js/components/drawer.js
- index.html
- src/styles/components.css
- src/js/main.js
Think of it like this
A sliding door on a good runner. Push it and it follows your hand. Let go with a flick and it glides on at the speed you gave it. Push it back while it glides and it turns without a bang. If you ask for less motion, it simply opens.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- scrim
- The dimmed layer behind a drawer or dialog. Clicking it closes the drawer.
- inert
- An HTML attribute that makes part of the page unfocusable and unclickable.
- focus trap
- Keeping Tab inside a dialog while it is open.
- wantOpen
- Where the drawer is heading, as opposed to where it is now.
Step by step
01Add the spring
Create src/js/motion/spring.js with the file below.
02Add the markup and the styles
Paste the Phase 9 block into index.html (before the script tag) and at the end of components.css. Add aria-controls, aria-haspopup and aria-expanded to the cart button.
03Add the drawer and wire it
Create src/js/components/drawer.js and add the two Phase 9 blocks to main.js. Click Cart: the drawer slides in and focus moves to the × button.
04Try to break it
Press Cart twice quickly. Drag the open drawer right and let go. Press Escape. Turn on reduced motion in DevTools (Rendering tab): the drawer fades.
Try it yourself
- 1
Make it bouncier
In
PHYSICSchangedamping: 34to20(ζ 0.51). Click Cart and probe: at 150 ms the drawer is 34 px past open (-34.0), at 250 ms -24.6, and it settles by 750 ms. Too lively for something opened every visit. - 2
Watch the reduced-motion fade
Switch on Reduced motion in the timeline and press Cart: the drawer is in place at the first frame (transform 0) and one 220 ms
opacitytransition runs.
Code & diagrams
The page after this phase: index.html's body, the stylesheets (base.css shortened), spring.js, drawer.js and the Phase 9 part of main.js inlined, since the preview cannot import local files (the loading bar, toasts, cart flight and motion toggle are left out). Press Cart, drag, flick, press Escape.
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
drawer: closed, inert true, cart button aria-expanded falseYour 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.
// spring.js: a spring you can interrupt (Phase 9).
// Units: position in px (or any unit), velocity in units per second, time in seconds.
const STEP = 1 / 240; // the physics step: 4.2 ms, always shorter than a screen frame
const MAX_FRAME = 0.064; // never simulate more than 64 ms of one frame: a long pause must not blow the maths up
const MAX_SECONDS = 20; // a spring with no damping would never rest: give up and land on the target
export function spring({
from = 0,
to = 1,
velocity = 0,
stiffness = 170, // k: how hard it pulls toward the target
damping = 26, // c: how much it resists moving
mass = 1, // m: how hard it is to speed up
restDelta = 0.1, // "arrived" means closer than this to the target ...
restSpeed = 1, // ... and slower than this
onUpdate,
onRest,
}) {
if (!(stiffness > 0) || !(mass > 0) || !(damping >= 0)) {
throw new RangeError("spring needs stiffness > 0, mass > 0 and damping >= 0");
}
let x = from;
let v = velocity;
let target = to;
let raf = 0;
let last = 0;
let spare = 0; // time that did not fill a whole step yet
let age = 0;
function frame(now) {
const dt = Math.min((now - last) / 1000, MAX_FRAME);
last = now;
age += dt;
spare += dt;
while (spare >= STEP) {
const acceleration = (-stiffness * (x - target) - damping * v) / mass;
v += acceleration * STEP; // semi-implicit Euler: new speed first ...
x += v * STEP; // ... then move with the new speed
spare -= STEP;
}
const resting = Math.abs(v) < restSpeed && Math.abs(x - target) < restDelta;
if (resting || age > MAX_SECONDS) {
x = target;
v = 0;
raf = 0;
onUpdate?.(x);
onRest?.();
return;
}
onUpdate?.(x);
raf = requestAnimationFrame(frame);
}
function start() {
if (raf) return;
last = performance.now();
spare = 0;
age = 0;
raf = requestAnimationFrame(frame);
}
start();
return {
// aim at a new target from where it is, at the speed it has: nothing restarts
retarget(next) {
target = next;
age = 0; // a new target is a new journey: the 20 s limit starts again
start();
},
stop() {
cancelAnimationFrame(raf);
raf = 0;
},
velocity: () => v,
};
}
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
Never let the spring rest
In PHYSICS set restDelta: 0, restSpeed: 0 (in the preview, edit the same line in the inlined drawer.js).
Myth vs fact
Myth
Dragging should be disabled for reduced motion.
Fact
The setting is about movement the page starts. A drag is movement you cause. What changes is the release: no spring, a fade.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The drawer moves with
transformand the scrim withopacityonly (green on the timeline), so a drag causes no layout. Theclickcapture listener that swallows the click after a drag fixes a real bug: a flick ending over Check out would press it.
Remember this
- 1
The problem this solves (Mei's and Lena's): the first drawer was a 400 ms transition that stopped dead when the cart button was tapped twice. Mei wants it to carry the speed of a finger. Ravi needs the bounce calm and a plain fade when motion is reduced.
- 2
What it adds, and how it plugs in.
spring.jsis the spring of Topic 9.2.drawer.jsis built on it and asks Phase 7'smotionAllowed()whether it may move.index.htmlgets a scrim and the drawer (role="dialog", a close button, a cart summary, a check-out button), and the cart button getsaria-controlsandaria-expanded.components.cssgets a Phase 9 block.main.jsimportscreateDrawer, builds the list from the Add buttons and opens the drawer from the cart button. Phase 8'scart.js(the count and the flying dot), Phase 7'smotion-safe.cssand every earlier block stay as they were. - 3
The feel. Stiffness 380, damping 34, mass 1: ζ 0.87. From a click at 0 ms the drawer is 112.3 px from open at 100 ms, 16.9 px at 200 ms, passes the end by 0.34 px at 400 ms and rests by 1.2 s. The scrim's opacity follows the position (0.65 at 100 ms).
- 4
State, drag and throw.
xis the distance from open,wantOpenwhere it is heading,motionthe running spring.open()andclose()start or retarget the spring: Cart pressed while closing turns it round. A press becomes a drag after 6 px of sideways movement, takes pointer capture and follows the finger, with rubber banding past open (a 400 px pull shows -49 px). On release, above 450 px/s the speed picks the direction, otherwise position plus 0.15 s of speed does. A tap passes through, a second finger is ignored,pointercancelsettles with speed 0. - 5
Keyboard, focus, right-to-left. Opening saves the focused element and focuses the close button. Escape and the close button close it and give focus back. Tab cycles inside. A closed drawer is
inertandvisibility: hidden. On a right-to-left page it slides in from the left (positions are multiplied by a side factor). - 6
Reduced motion. With
motionAllowed()false the drawer does not travel: it is placed at once and itsopacityfades over 220 ms with--ease-out(0.80 at 60 ms, 0.97 at 120 ms, 1 at 300 ms). A drag still works, since it answers your own hand; release settles without a spring. - 7
What the launch site can do now that it could not before: open a cart drawer that follows your finger, can be thrown shut, survives double taps and works from the keyboard. Still missing: the list comes from the Add buttons, not from
cart.js(which only counts), Phase 10 animates the gallery with FLIP, and no real touch screen has tried the drawer (the checks use scripted pointer events).
Explain it without notes
What does the drawer do when Cart is pressed while it is closing, and why?
Why is a closed drawer both inert and visibility: hidden?
Practice
Add a token for the drawer's physics to tokens.css and read it in the script.
Why does the scrim need pointer-events: none when the drawer is closed?
Done when you can
You can open, drag, throw and retarget the drawer
You can say how a closed drawer stays out of the focus order
You can name what is still missing and which phase adds it