Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 9.7Intermediate ~45 min · shot 7 of 7

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.

0/7 · 0%

Before you start

You will learn

  • Add spring.js and drawer.js, then the drawer markup to index.html
  • Add the drawer styles to components.css and the wiring to main.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. 1

    Make it bouncier

    In PHYSICS change damping: 34 to 20 (ζ 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. 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 opacity transition runs.

Code & diagrams

The launch page, with the cart drawer

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.

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

drawer: closed, inert true, cart button aria-expanded false

Your project files

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

src/js/motion/spring.jsjs

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

terminal
$ Open the drawer, close it with × and look 1.5 s later
── what you'll see ──
The drawer is offscreen (x 320) but `data-state` is still "open" and `inert` is false. It becomes closed only after the 20 s safety stop.

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 transform and the scrim with opacity only (green on the timeline), so a drag causes no layout. The click capture listener that swallows the click after a drag fixes a real bug: a flick ending over Check out would press it.

Remember this

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

    What it adds, and how it plugs in. spring.js is the spring of Topic 9.2. drawer.js is built on it and asks Phase 7's motionAllowed() whether it may move. index.html gets a scrim and the drawer (role="dialog", a close button, a cart summary, a check-out button), and the cart button gets aria-controls and aria-expanded. components.css gets a Phase 9 block. main.js imports createDrawer, builds the list from the Add buttons and opens the drawer from the cart button. Phase 8's cart.js (the count and the flying dot), Phase 7's motion-safe.css and every earlier block stay as they were.

  3. 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. 4

    State, drag and throw. x is the distance from open, wantOpen where it is heading, motion the running spring. open() and close() 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, pointercancel settles with speed 0.

  5. 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 inert and visibility: hidden. On a right-to-left page it slides in from the left (positions are multiplied by a side factor).

  6. 6

    Reduced motion. With motionAllowed() false the drawer does not travel: it is placed at once and its opacity fades 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. 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

01

What does the drawer do when Cart is pressed while it is closing, and why?

02

Why is a closed drawer both inert and visibility: hidden?

Practice

01

Add a token for the drawer's physics to tokens.css and read it in the script.

02

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