Flying Instead of Teleporting
When one state replaces another, the default is not a crossfade. Work in this order: find what persists across the boundary and keep it continuous, then let everything that does not persist enter and exit along the axis that matches the spatial relationship. If nothing persists and there is no spatial relationship to express, a hard cut beats a decorative crossfade — a fade is what you ship when you have not worked out what moved. Benji Taylor's framing is the one to hold: "we fly instead of teleport", and each transition "serves a purpose from an architectural perspective, aiding users in understanding their path from A → B." motion owns one element's curve, duration, and enter shape; this skill owns which elements move, in what order, in which direction, and what stays continuous — and if the clock is a finger rather than a timer, it is gestures.
Find the mechanism the project already has before choosing one. Grep for startViewTransition, view-transition-name, ::view-transition, @view-transition, layoutId, AnimatePresence, Motion's layout prop, a FLIP helper, or a framework route hook (template.tsx, useNavigation, a router's transition callback). A codebase that already runs View Transitions gets its next shared element in View Transitions; a codebase on Motion layoutId gets layoutId. Two shared-element systems in one app produce two different physics for the same gesture, which is worse than either alone.
Quick Reference
| When | Open |
|---|---|
| More than one element enters at once, or a tab switch, route change, or list reorder needs an exact delay and a direction | choreography.md |
| The same component exists on both sides of the boundary — the case where a wrong implementation quietly duplicates it | shared-element.md |
Which mechanism?
Walk the ladder and stop at the first match.
- The node stays mounted and only its layout changed → measure and invert (FLIP). Nothing else is needed.
- The same component moves to a different place in the same tree → the library's shared-layout primitive (
layoutId), which does FLIP for you and keeps one node. - The boundary is a full document or route change → View Transitions API:
document.startViewTransition(), with a uniqueview-transition-nameon each persisting element. - Nothing persists → no shared element. Direction-matched enter and exit, and consider whether it should animate at all.
Do not skip from 1 to 3 because View Transitions is newer. A route-level snapshot for an element that never unmounted is more machinery, more failure modes, and less interruptible.
Core Principles
Direction must match the spatial relationship. A transition that moves the wrong way actively teaches the user a false map of the app. Tap a tab to the left of the current one and the content travels left: the sign of the travel is the sign of the index delta, not a constant. Exception: a surface dismisses along the axis it arrived on regardless of which control closed it — a sheet that rose from the bottom leaves through the bottom even when dismissed by a header button.
A component that persists must never duplicate itself mid-transition. Two copies of the same thing on screen is the single most common shared-element bug and Benji Taylor's named pet peeve; it converts continuity into a magic trick. One node, one
view-transition-nameorlayoutId, no crossfade of two copies. Exception: an outgoing item that is genuinely a different record from the incoming one is not a duplicate — that is a swap, and it gets an exit and an enter.Stagger
30–50ms, with80msas a hard ceiling. Below30msthe cascade is invisible and you paid for nothing; past50msthe list starts reading as slow. guidelines.sh publishes30–50ms; Emil Kowalski's range runs to80ms, which is the ceiling, not the default. Exception: long lists — clamp the index (min(i, 8)) so total choreography never exceeds400ms, rather than lowering the per-item delay.Stagger is decorative and must never block interaction. The user can click the fourth row before it has finished arriving, and the interface must let them. Never gate
pointer-eventson the animation, and never delay data fetching behind it. Exception: a first-run sequence may hold focus to guide attention — it still may not hold the pointer.A hard cut between two related views is a finding, not a safe default. This is the "avoiding static transitions" rule: where two states share structure, cutting between them makes the user re-parse the screen. "A lifeless product feels like a dead product, and a dead product feels uncared for" (Benji Taylor). Exception: the frequency gate in
motionoutranks this entirely — a view a power user switches a hundred times a day is correctly instant, and Raycast is right.Elements that persist take
ease-in-out; elements that enter or leave takeease-out. A persisting element is already on screen and needs the accelerate-then-brake shape; an arriving one needs the fast start that reads as response. Exception: if a persisting container also crossfades its contents, both halves take the container's duration and curve exactly — paired elements on different clocks read as broken.Enter and exit travel the same path. If a panel arrives from the right it leaves to the right. In-from-right, out-through-the-bottom destroys the spatial model the transition was built to teach. Exception: a dismissal the user performed by dragging leaves in the direction of the drag — that belongs to
gestures.Page-level transitions get
300–400msand never delay interactivity. They cover more distance than a component, so they earn more time than the product ceiling — but the incoming view must be interactive from its first frame, and no data fetch may hide behind the animation. Exception: marketing and launch pages, which run tomarketing-pages, not this band.Name the A → B before writing any of it. If you cannot say in one sentence what the transition explains — this detail came out of that row, this step follows that step — the transition has no purpose and you are decorating a state change. Exception: none. This is the gate every other principle assumes has been passed.
Smell / Fix
| Smell | Fix |
|---|---|
| Crossfade between two views that share structure | Identify the persisting element and move it; fade only the remainder |
| Both tab directions animate the same way | Sign the travel from the index delta |
| The shared element appears twice mid-flight | One node with one view-transition-name / layoutId |
Every list item delayed by 120ms |
30–50ms, index clamped so the total stays under 400ms |
| List not clickable until the stagger finishes | Stagger never gates pointer-events |
| Panel enters from the right, exits downward | Same path in both directions |
| Route transition hides a pending fetch | Animate the shell; stream the data independently |
layoutId and View Transitions both in the codebase |
Pick one; two shared-element systems is two physics |
Persisting element on ease-out while its container is on ease-in-out |
One curve and one duration for the paired unit |
| A transition nobody can state the purpose of | Delete it |
Output
State the choreography as a table before writing code: element, role (persists / enters / exits), direction, delay, and the mechanism. Give real numbers — 40ms, 320ms, translateX(-24px) — and name the shared elements explicitly. A transition described as "smooth" has not been designed.
Checklist
- The A → B this transition explains is stated in one sentence
- Persisting elements identified; each has exactly one node and one name
- Direction derives from the spatial relationship, not a constant
- Enter and exit share a path
- Stagger
30–50ms, total clamped under400ms, nothing blocked by it - Persisting elements
ease-in-out, arriving/leavingease-out, paired units identical - Route transitions
300–400ms, incoming view interactive on frame one - Frequency gate from
motionapplied — a high-frequency switch stays instant - Built in the project's existing shared-element mechanism, not a second one
-
prefers-reduced-motionkeeps the crossfade and drops the travel