Pitfalls

Seven bugs found in a production app that used an earlier version of this transition, and what morphcard does about each one.

These came from a real app built with a View Transitions based version of the same choreography. Each one is now library behaviour, and each is covered by a browser test in tests/e2e/morph.spec.ts.

1. A flight to nowhere

What happened. From a detail screen the user went to Settings. The detail heading had a shared name, Settings had no element with that name, and a lone snapshot of the old heading floated across the Settings screen.

Rule. Never fly when the destination is missing or not on screen. morphcard checks both ends of every pair before planning it. A missing sheet element gives missing-sheet-element, one scrolled out of view gives sheet-element-offscreen, and closing to nothing (close({ to: null })) fades the whole sheet out.

2. A flight from off screen

What happened. A detail opened from a deep link had no card on screen. On return, the list row was far below the fold. The heading flew in from, or out to, a point outside the viewport.

Rule. A card that the reader can see, even only a strip of it cut off by the edge of the screen or of its list, grows from the part that shows and shrinks back into it. A card with no visible part (at least 8 px on each side, inside the viewport and every ancestor that clips it) gives card-offscreen, and the transition fades. open(null) is the deep-link case.

A sheet smaller than the screen, such as a centred panel, may not cover the card. It then starts moved over the card and slides into its place while it grows, on the same curve, so the surface still starts on the card. Sheets with a position: fixed child are never moved, because a transform would drag that child; they only use the part of the card they cover.

3. Back that lingers

What happened. Back used the same timing as open, and the big heading was the last thing to shrink, still moving over a list that had already settled.

Rule. Close is 300 ms against 400 ms for open, and the background, scrim and surface end together. The sheet heading fades out in the first 90 ms and the card copy takes over, so the last thing moving is the card's own text.

4. Scroll lost on return

What happened. The list was scrolled when the user opened an item. On return the target rectangle was measured before the scroll position came back, so the flight aimed at the wrong place. Focusing the card then scrolled the list again.

Rule. The list's scroll position is saved on open and put back on close before anything is measured. Focus returns with preventScroll: true.

5. Stale cleanup

What happened. A second transition started while the first was running. When the first finished, its cleanup cleared the state of the second: the ghost vanished mid-flight and the page kept a stray lock.

Rule. Each transition has a generation number. Finish handlers compare it before doing anything. After any sequence (open, close, open during close, close during open, a double click) the tests compare every attribute on the page with the state before, and look for ghosts and inline styles.

6. Transforms drag fixed children

What happened. A container with a position: fixed bottom bar was scaled during the transition. A transformed element becomes the containing block for its fixed descendants, so the bar left the bottom of the screen and moved with the container.

Rule. The sheet grows by clip-path. A centred sheet may also translate to reach the card, but only when it has no fixed descendants. The background is only scaled when it has no fixed descendant. Staggered blocks and the dock fall back to opacity if they contain one.

7. Motion for people who asked for none

What happened. Reduced motion shortened the animation but kept the movement.

Rule. Reduced motion means opacity only, with the surface before the content so two screens of text never overlap. See Accessibility.

Other things to watch

  • [hidden] must hide. A CSS rule like .sheet { display: flex } beats the hidden attribute. Add [hidden] { display: none !important }.
  • Keep the sheet full size. morphcard reads the sheet's rectangle as the end of the flight. A sheet that is still sliding in from CSS gives the wrong target.
  • Fill the sheet before measurement. Use open({ key, item }) and render from morph.item, or pass a synchronous update callback to open(card, update). Content added later is not included in the measured flight.
  • One element per key on each side. If the sheet has two data-morph="title" elements, the first one wins.

Made by Lucas Piera