Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 10.6Intermediate ~34 min · shot 6 of 6

Build: The Filterable Gallery

In one line

Add flip() and initGallery(): the colour filter slides the photos that stay, fades the ones that leave where they were, and fades in the ones that return. The specs open to their natural height, and reduced motion removes the travel.

0/6 · 0%

Before you start

You will learn

  • Add src/js/motion/flip.js and src/js/components/gallery.js
  • Add the status line to index.html and the Phase 10 block to components.css
  • Replace the instant filter in main.js with initGallery()

You write code in

In your lumen-motion project (npm run dev) and in the editor on this page: add two files and change three

Files you touch

  • src/js/motion/flip.js
  • src/js/components/gallery.js
  • index.html
  • src/styles/components.css
  • src/js/main.js

Think of it like this

A shop window being re-dressed while the shop stays open. The assistants do not hide the window behind paper and unveil the new one. They carry each item to its new place where customers can see it, and take the unwanted ones away through the front.

Words you'll meet

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

initGallery(root)
Wires the colour buttons of the gallery to flip(). It returns { destroy() } so a page can remove its listener.
status line
A visible role="status" paragraph that says how many photos are shown, for everyone and for screen readers.
follower
An element below the grid (here the Specs section) that is passed to flip() so it glides when the grid changes height.
fade-out copy
A clone of a removed photo placed at its old box, which fades and then removes itself.

Step by step

01Add the two files

Create src/js/motion/flip.js and src/js/components/gallery.js with the contents below. flip.js imports motionAllowed from Phase 7's file.

02Add the CSS and the status line

Append the Phase 10 block to components.css and add <p class="gallery-status" id="gallery-status" role="status"> above .photos in index.html.

03Change main.js

Import initGallery, delete Phase 0's filter loop, and call initGallery().

04Try it

Press Blue, then All, and Blue twice in a row. Press Blue and Ink 100 ms apart at 0.25×. Switch on Reduced motion and press Blue. Open Battery in the specs.

Try it yourself

  1. 1

    Interrupt a filter

    At 0.25×, press Blue, and 400 ms later press All. The two blue photos turn round from where they are, and the four leaving copies keep fading. Nothing jumps.

  2. 2

    Press the same filter twice

    Press Blue twice quickly. The second press changes nothing: initGallery() returns early when the colour is already chosen.

  3. 3

    Reduced motion

    Switch on Reduced motion and press Blue: four short fades (120 ms), no slide, and the status line still says Showing 2 of 6 photos.

Code & diagrams

The gallery and specs after Phase 10

The gallery and specs sections of the page with the project's flip.js and gallery.js inlined (local imports do not run in the preview; a one-line stand-in replaces prefers-reduced.js). Filter the gallery, interrupt it, and switch on Reduced motion.

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/js/motion/flip.jsjs

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

// flip.js: First, Last, Invert, Play (Phase 10).
// flip(elements, change, options) measures where the elements are, runs change(), measures again, and plays each
// element that moved from its old place to its new one. Only transform animates, so it stays on the compositor.
import { motionAllowed } from "./prefers-reduced.js";

const running = new WeakMap(); // element -> its FLIP animation, so a newer flip can replace it

function measure(el, scroller) {
  const box = el.getBoundingClientRect(); // includes a transform in flight: the true on-screen box
  const s = scroller ? scroller.getBoundingClientRect() : { left: 0, top: 0 };
  return {
    box,
    x: box.left - s.left + (scroller ? scroller.scrollLeft : 0), // measured from the scroller's content, so a scroll is not a move
    y: box.top - s.top + (scroller ? scroller.scrollTop : 0),
    shown: el.getClientRects().length > 0, // false for display: none
    opacity: Number(getComputedStyle(el).opacity),
  };
}

// An element that left has no box any more, so a copy of it fades out where it was.
function fadeOutCopy(el, before, duration, move) {
  const copy = el.cloneNode(true);
  copy.hidden = false;
  copy.removeAttribute("id");
  copy.setAttribute("aria-hidden", "true");
  copy.inert = true;
  Object.assign(copy.style, {
    position: "fixed", margin: "0", zIndex: "5", pointerEvents: "none", transition: "none",
    left: before.box.left + "px", top: before.box.top + "px", width: before.box.width + "px", height: before.box.height + "px",
  });
  document.body.append(copy);
  const frames = [{ opacity: before.opacity }, { opacity: 0, transform: move ? "scale(0.92)" : "none" }];
  const fade = copy.animate(frames, { duration: move ? Math.min(duration, 200) : 120, easing: "ease-out", fill: "forwards" });
  fade.finished.then(() => copy.remove(), () => copy.remove());
  return fade;
}

export function flip(elements, change, { duration = 320, easing = "cubic-bezier(0.22, 1, 0.36, 1)", scroller = null } = {}) {
  const items = [...elements];
  const first = items.map((el) => measure(el, scroller)); // First: read everything before writing anything
  for (const el of items) running.get(el)?.cancel(); // forget the old transform, so Last is measured clean
  change(); // Last: the DOM changes, no animation yet
  const move = motionAllowed(); // reduced motion: nothing travels
  const plays = [];
  items.forEach((el, i) => {
    const a = first[i];
    const b = measure(el, scroller);
    if (a.shown && b.shown) {
      const dx = a.x - b.x;
      const dy = a.y - b.y;
      if (!move || (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)) return; // did not move: no animation, no layer
      const play = el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration, easing }); // Invert, Play
      running.set(el, play);
      play.finished.then(() => running.get(el) === play && running.delete(el), () => {});
      plays.push(play);
    } else if (a.shown && !b.shown) {
      plays.push(fadeOutCopy(el, a, duration, move)); // left
    } // arrived (not shown before): the CSS @starting-style does the entrance
  });
  return Promise.allSettled(plays.map((p) => p.finished)).then(() => {});
}

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

Forget the section below

In gallery.js change flip(below ? [...photos, below] : photos, () => { to flip(photos, () => {.

terminal
$ Press Blue and pause at 0 ms
── what you'll see ──
`#specs` has `transform: none`: the Specs section jumps up under the fading photos at once, while the photos glide.

Myth vs fact

Myth

Hiding photos with hidden is enough because the browser will animate the rest.

Fact

The browser jumps. Without FLIP the photos that stay change places at once.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The copies are position: fixed at the old box, so if the page scrolls during their 200 ms they stay put on screen. It is short enough to go unnoticed; a long exit would need absolute positioning inside the scroller.

  • ▸

    running.get(el)?.cancel() happens after First is measured and before the change. That order is what makes a second click start from the visible position. Deleting the cancel makes a photo jump when a second filter interrupts the first (Topic 10.2 measured a 23 px jump).

  • ▸

    Phase 11 teaches View Transitions, which snapshot a whole page change. A list that changes inside one page can still use FLIP, which only animates the elements you pass.

Remember this

  1. 1

    Why the site needs this now (Mei's storyboard): the gallery filter hides photos at once, and testers thought they had gone. Phase 10's techniques give each photo a path, so the eye follows.

  2. 2

    What it adds. flip.js exports flip(elements, change, options): First, Last, Invert and Play with WAAPI on transform, skipping elements that did not move, fading a copy of any that left, ignoring any that arrived (CSS does that), cancelling a running flip before measuring, and moving nothing under reduced motion. gallery.js exports initGallery(root). components.css gains a Phase 10 block with @starting-style, interpolate-size and ::details-content. index.html gains a status line. main.js calls initGallery() in place of the instant filter.

  3. 3

    What happens when you press Blue (measured on the build example): the two blue photos slide with a 320 ms transform, the four others fade out as copies for 200 ms, and the Specs section slides up with them (a third track on #specs) because initGallery() hands flip() the section below. Press All: four photos fade and scale in for 220 ms through @starting-style.

  4. 4

    Reduced motion. Because flip() calls motionAllowed() (Phase 7), nothing travels: the four leaving photos fade in 120 ms, and the staying ones just take their new places. The CSS twin in components.css (--enter-scale and --spec-dur, set for the media query and for data-motion) turns off the scale on entry and the slide-open on the specs.

  5. 5

    What the launch site can do now that it could not before: filter the gallery with every photo accounted for, announce the result to screen readers ("Showing 2 of 6 photos"), interrupt a filter with another one without a jump, and open the specs smoothly. What is still missing: the move to the ride page (Phase 11 adds View Transitions), the scroll story (12) and the logo and route drawing (13).

Explain it without notes

01

Why does the gallery pass the next section to flip()?

02

What does flip() do for reduced motion?

Practice

01

Add a "Sort by name" button to the gallery that reorders the photos by caption with FLIP. What goes inside change()?

02

A designer wants the photos to move 500 ms with ease-in-out. Which line changes?

Done when you can

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

  • You can say what a filter does to staying, leaving and arriving photos

  • You can test the gallery with interruption and with reduced motion