10 Object permanence 10 Object permanence 10 Object permanence 10 Object permanence
A thing that vanishes here and appears there is two things, unless motion shows it is one.
Hide a toy under a cloth in front of a very young baby, and the baby stops looking for it. Out of sight is gone. Some months later, the same baby lifts the cloth. It has learned that things go on existing when you can’t see them, and from then on it never has to think about it again. The world keeps its things.
A screen doesn’t. When a layout changes in a single frame, the screen doesn’t move anything: some pixels go dark and others light up. Whether the thumbnail you tapped and the big picture that replaced it are the same thing is a guess the eye has to make. Motion can take the guess away. If the thumbnail travels to its new place and grows into the picture, it was never gone.
Chapter 9 was about things that arrive and leave. This chapter is about the thing that stays and changes place or size, and how to keep the eye on it.
One thing, or two?
Tap a row to open it, then go back. Do it once with Shared elements on and once with it off.
With shared elements on, the colour block you tapped travels and grows into the picture at the top of the detail view, and the title moves into the heading. You never lose the thing you tapped. It just went somewhere. Going back, it returns to its own row, so you know where you were.
With them off, the list dissolves and the detail view fades in over it. Nothing is broken, but you saw two screens, not one thing moving. Going back, the eye has to search the list for the row it left.
What did you open?
Watch a tile open and close, with motion and without. Then ask which tile it was.
A tile opens into its detail view, then closes again. Try it with motion, then without.
Why identity matters
The eye is always matching what it sees now against what it saw a moment ago. When something moves a little between frames, the match is easy: the same thing, moved. When it jumps across the screen, or changes size, in a single frame, there is nothing to match. What’s left is a disappearance and an appearance, and the eye’s default reading of that is two different things.
A shared-element transition exists to keep identity. The motion says this is the thing you tapped, and it says it without a label.
It matters most in three places:
- Something opens into more of itself. A thumbnail into a photo, a card into an article, a row into its detail page.
- Something moves within a list. A table re-sorts, a to-do is dragged up, a new item at the top pushes the rest down. If every row jumps, you lose your place. If the rows slide, you keep it.
- Something small marks where you are. A tab’s underline, a selected segment. If it slides, it is one marker. If it blinks from tab to tab, it is a new marker every time.
It matters just as much not to do it when the two things aren’t the same. A morph is a claim of identity. If the title of one chapter morphed into the title of the next, it would say they were the same chapter. Between different things, the honest move is a cut, or a quick cross-fade.
FLIP by hand
Say a card moves from one list to another. In the DOM that’s one line: append it to the other list. The browser lays out the page again, and the card is simply in its new place. To make it travel instead, the obvious idea is to animate top and left, or width and height, from the old values to the new ones.
Don’t. Those are layout properties. Change one and the browser has to work out again where every affected box goes, on every frame of the animation. A transform is applied after layout: it moves or scales a box’s pixels without changing where anything else goes. The browser can often hand that to the compositor, the part that stacks finished layers into the picture on screen, so nothing is laid out or repainted per frame. Layout is expensive to animate. Transforms are cheap.
FLIP gets the best of both. Let the layout change happen at once, for real, then use a transform to fake the trip. It is named after its four steps:
- First. Measure the element where it is now, with
getBoundingClientRect(). - Last. Make the change. The element jumps to its new place. Measure it again.
- Invert. Work out the transform that puts it back (the inverse of the jump the layout just made): translate by First minus Last, and scale by First’s size over Last’s, both about the box’s top-left corner (
transform-origin: 0 0). Apply it. The element looks as if it never moved, though its layout is already at Last. - Play. Animate the transform to
none. The element travels from First to Last on transforms alone.
const first = el.getBoundingClientRect(); // F changeLayout(); // the DOM moves const last = el.getBoundingClientRect(); // L const from = `translate(${first.x - last.x}px, ${first.y - last.y}px)`; el.style.transform = from; // I el.style.transform = ''; el.animate([{ transform: from }, { transform: 'none' }], timing); // P
Step through it with Next beat. After Last, the tiles have already jumped: the red outlines are their new layout. After Invert, each tile sits on its blue dashed outline again, where it started, although its layout is at Last. Play only takes a transform away.
A worked example. A card was at left: 100px and is now at left: 300px, so dx is 100 − 300 = −200: shift it 200px left and it is drawn on top of where it started. Animating that −200px to 0 slides it from the old place to the new one. Say the card also grew from 100px wide to 200px: sx is 100 ÷ 200 = 0.5, drawn half as wide, back at its old size, and animating 0.5 to 1 grows it. The origin matters here: transform-origin: 0 0 keeps the top-left corner fixed while it scales, which is the corner left and top measured.
Two details make it work:
- No flash. First, Last and Invert run in the same task (one uninterrupted run of your JavaScript), before the browser paints. The screen never shows the element at Last without its transform. The first frame anyone sees is the element at First.
- Measuring is the cost. Reading
getBoundingClientRect()right after a change makes the browser lay out the page there and then. FLIP pays for layout once or twice, up front, instead of on every frame.
Scale is where FLIP gets awkward. Scaling a box scales everything in it: text squashes, rounded corners flatten, borders thin. For a photo that’s fine. For a card full of text, keep the scale small, or counter-scale the children.
A FLIP can also be interrupted without a jump. getBoundingClientRect() includes transforms, so if a new FLIP starts mid-flight, First is where the element is on screen, and the new trip starts from there. Unlike a spring (chapter 5), it doesn’t carry its speed over, but it doesn’t teleport either.
The platform version
FLIP means measuring and inverting each element yourself. The View Transitions API does it for you, for the whole page at once. You call document.startViewTransition() with a function that changes the DOM, and the browser:
- Captures the page as it looks now: the old state.
- Runs your function. The DOM changes in one step, but the screen isn’t updated yet.
- Captures the new state.
- On a layer above the page, animates from the old state to the new one. By default that’s a cross-fade of the whole page.
On its own, that’s a dissolve. The part that keeps identity is view-transition-name. Give an element a name (the CSS property view-transition-name: photo, any word you choose) and the browser captures it apart from the rest of the page. If an element in the old state and an element in the new state have the same name, the browser treats them as one thing: it takes the old box and the new box and animates between them. That’s FLIP, done by the browser, and it works even when the two were never the same DOM node.
Each name gets a small tree of pseudo-elements, which you style with CSS:
| Pseudo-element | What it is | By default |
|---|---|---|
::view-transition-group(name) | the box that travels | moves and resizes from the old box to the new |
::view-transition-old(name) | a picture of the old state | fades out |
::view-transition-new(name) | a picture of the new state | fades in |
Everything without a name is captured together, under the name root. That’s the whole-page cross-fade.
Two rules. A name may belong to only one element at a time in each state; if two share it, the browser skips the transition. So in a list, name only the item that was tapped, just before the transition starts. And while the transition runs, you are watching pictures on a layer above the page. The DOM underneath has already changed.
Figure 10.1 is a real view transition. The colour block and the title carry names only while Shared elements is on, and only in the row you tapped. Turn it off and nothing has a name, so all that’s left is root: the dissolve.
Between pages, and in React
A view transition can also run from one page to another. Put @view-transition { navigation: auto; } in the CSS of both pages, and a navigation between them animates, with no JavaScript. It only works within one origin. Names work the same way: a list page can give each thumbnail its own name, photo-12 for item 12, and the detail page for item 12 gives its hero the same name. The thumbnail grows into the hero across the page load.
Because every name must be unique, a list of thumbnails needs a rule per name. view-transition-class fixes that: give all the photos the same class, and style them with one selector, ::view-transition-group(.photo).
Support is uneven, and cross-document transitions and view-transition-class are the newest parts, so check current browser tables before relying on them. Where the API is missing, document.startViewTransition is undefined and pages navigate the way they always have. So check for it, and when it isn’t there, just make the change.
In React, Motion does FLIP for you. Add layout to a motion element and whenever a render moves or resizes it, it animates there with transforms. Give two elements the same layoutId, and when one replaces the other, the new one grows out of the old one’s box. That’s a shared element without the View Transitions API, and like any Motion animation it can be interrupted mid-flight.
Step a FLIP
This is FLIP written out, running on the stage. The box’s layout jumps to a new place in one step; the four steps make it travel. Slow it to 0.1× with the speed control, or step it with the arrow keys on the scrubber, and watch the box.
- Step through the first few frames. The layout has already jumped to Last, but the box is still drawn at First. The transform is all that holds it there.
- Set
durationto 0. That’s the change without FLIP: the box just jumps. - Change
leftandtopto anything you like. FLIP never needs to know where the layout will put the box. It measures, then inverts.
The notation
FLIP by hand works anywhere and on anything. The View Transitions API lets the browser do the measuring. Motion’s layout does it inside React. All three share one idea: measure where the thing was and where it is now, then carry it across without animating the page’s layout.
// FLIP: First, Last, Invert, Play.
// 420ms with an ease-in-out curve: a trip across the screen, so longer
// than a small fade. Try 250 and 700 and feel the difference.
const timing = { duration: 420, easing: 'cubic-bezier(.65, 0, .35, 1)' };
// el: the element to move. change: a function that moves it (for real).
function flip(el, change) {
// F: a rectangle {left, top, width, height} in pixels, where the
// element is drawn right now. It includes any transform still running.
const first = el.getBoundingClientRect();
// Stop a FLIP that is still running, so it cannot fight this one.
el.getAnimations().forEach((a) => a.cancel());
// L: the layout jumps, in one step. Measure where it landed.
change();
const last = el.getBoundingClientRect();
// I: the transform that puts it back on First. Each number is the
// distance from where it is now (Last) to where it was (First).
const dx = first.left - last.left; // px to shift right
const dy = first.top - last.top; // px to shift down
const sx = first.width / last.width; // 1 = same width, 2 = twice as wide
const sy = first.height / last.height;
const invert = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
// P: play the transform away, to none. transformOrigin '0 0' is the
// top-left corner: that is the corner the left and top measurements
// describe, so the scale must grow from there, not from the centre.
return el.animate(
[
{ transformOrigin: '0 0', transform: invert },
{ transformOrigin: '0 0', transform: 'none' },
],
timing,
);
}
// Click a card in the todo list: flip() measures it, the callback moves
// it to the top of the done list, and it slides there. closest() finds
// the card even when the click lands on something inside it.
const todo = document.querySelector('.todo');
const done = document.querySelector('.done');
todo.addEventListener('click', (event) => {
const card = event.target.closest('.card');
if (card) flip(card, () => done.prepend(card));
}); /* Between pages: put this in the CSS of both pages. Same origin only. */
@view-transition {
navigation: auto;
}
/* The same name, before and after, marks the same thing, and a name is
unique within a page. So name each item's photo in the markup:
list page: <img class="photo" style="view-transition-name: photo-12">
detail page: <img class="photo hero" style="view-transition-name: photo-12"> */
/* One class for all of them, so one rule styles every trip. (Here,
.photo is also the ordinary class from the markup above: the
view-transition-class value is a separate label the pseudo-elements match.) */
.photo {
view-transition-class: photo;
}
/* The group is the trip: it moves and resizes from the old box to the new.
420ms and the same curve as the FLIP above, because it is the same trip. */
::view-transition-group(.photo) {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(.65, 0, .35, 1);
}
/* Everything unnamed is "root", and cross-fades: the old and new
pictures fade. 180ms: the page is a background to the trip, so it
should not outlast it. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
} // Name only the thumbnail that was tapped, then change the DOM inside
// startViewTransition(). `showDetail` is your own update (return its
// promise if it is async). The detail's hero has `view-transition-name:
// photo` in your CSS, so the name is on one element in each state.
async function openDetail(card) {
const thumb = card.querySelector('.thumb');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.startViewTransition || reduce) {
showDetail(card.dataset.id); // no API, or no motion wanted: just change
return;
}
// 1. The browser captures the OLD state when we call
// startViewTransition, so the tapped thumbnail must be named now.
thumb.style.viewTransitionName = 'photo';
const transition = document.startViewTransition(() => {
// 2. This runs after the old state is captured. Take the name off
// the thumbnail: the hero (in the new state) has it, and two
// elements may not share a name.
thumb.style.viewTransitionName = '';
return showDetail(card.dataset.id);
});
// 3. Resolves when the animation has finished (optional here).
await transition.finished;
} import { motion } from 'motion/react';
const move = { duration: 0.42, ease: [0.65, 0, 0.35, 1] };
// layout: when the card's place in the list changes, Motion measures
// before and after and FLIPs it for you. It also FLIPs the size.
function Card({ item, onOpen }) {
return (
<motion.li layout transition={move} onClick={() => onOpen(item.id)}>
<motion.img
layoutId={`photo-${item.id}`}
transition={move}
src={item.src}
alt=""
/>
{item.title}
</motion.li>
);
}
// layoutId: the same id as the card's photo. When Card leaves and Detail
// arrives, Motion treats them as one thing and grows the new from the old.
function Detail({ item }) {
return (
<motion.img
layoutId={`photo-${item.id}`}
transition={move}
src={item.src}
alt=""
/>
);
} A morph tells the eye that a thing is the same thing. It does not tell it where the thing lives. Chapter 11 takes that next question: when a new screen arrives, which side does it come from, and where does the old one go?
Which one kept it?
Two versions of the same change. Pick the one that keeps track of the thing that moved, then name why.
Two versions. Which is better? Then say why, in one word.Taste: judging, then naming the reason.
5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.