Shots in this scene
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.
Before you start
You will learn
- Add
src/js/motion/flip.jsandsrc/js/components/gallery.js - Add the status line to
index.htmland the Phase 10 block tocomponents.css - Replace the instant filter in
main.jswithinitGallery()
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
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
Press the same filter twice
Press Blue twice quickly. The second press changes nothing:
initGallery()returns early when the colour is already chosen. - 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 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.
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.
// 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, () => {.
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: fixedat 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
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
What it adds.
flip.jsexportsflip(elements, change, options): First, Last, Invert and Play with WAAPI ontransform, 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.jsexportsinitGallery(root).components.cssgains a Phase 10 block with@starting-style,interpolate-sizeand::details-content.index.htmlgains a status line.main.jscallsinitGallery()in place of the instant filter. - 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) becauseinitGallery()handsflip()the section below. Press All: four photos fade and scale in for 220 ms through@starting-style. - 4
Reduced motion. Because
flip()callsmotionAllowed()(Phase 7), nothing travels: the four leaving photos fade in 120 ms, and the staying ones just take their new places. The CSS twin incomponents.css(--enter-scaleand--spec-dur, set for the media query and fordata-motion) turns off the scale on entry and the slide-open on the specs. - 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
Why does the gallery pass the next section to flip()?
What does flip() do for reduced motion?
Practice
Add a "Sort by name" button to the gallery that reorders the photos by caption with FLIP. What goes inside change()?
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