One shape, many interface states
A convincing interface transition does not begin with animation. It begins with a stable model of identity. When the same surface survives a structural change, motion can explain what changed without making the user reconstruct the interface from scratch.
Model the states before animating them
The experiment has three visual states. Compact is a low attention entry point. Player exposes transport and progress. Album adds context without leaving the object that initiated the action.
Each state owns its markup and dimensions. The transition does not invent an intermediate component. It lets the browser interpolate between two valid interface states.
Keep the visual state finite
A finite union prevents impossible values and gives every transition an explicit destination. The DOM update is synchronous because the browser needs a deterministic before and after state.
type PlayerState = 'compact' | 'player' | 'album';
function setState(nextState: PlayerState) {
const update = () => {
root.dataset.state = nextState;
syncAccessibility(nextState);
};
if (!document.startViewTransition || prefersReducedMotion()) {
update();
return;
}
surface.style.viewTransitionName = 'one-shape';
const transition = document.startViewTransition(update);
transition.finished.finally(() => {
surface.style.removeProperty('view-transition-name');
});
}The browser animates captured pixels
startViewTransition() temporarily separates DOM mutation from visual presentation. The browser captures the old pixels, runs the update callback, captures the new pixels, then exposes both images through generated pseudo elements.
Layout is calculated for the destination once. The compositor can then animate the snapshots without forcing JavaScript to calculate every intermediate width, height and corner radius.
A generated rendering tree
The transition is rendered through a small pseudo element tree. The group controls shared geometry. The image pair isolates blending. The old and new nodes hold the captured pixels.
::view-transition
::view-transition-group(one-shape)
::view-transition-image-pair(one-shape)
::view-transition-old(one-shape)::view-transition-new(one-shape)readyThe pseudo elements exist and their animations are about to start.
updateCallbackDoneThe update callback has completed, including any returned promise.
finishedThe visual transition has completed or was skipped.
Identity is the contract
A transition name does more than select an animation. It asserts that the old pixels and the new pixels represent the same conceptual object.
If the identity is missing, the browser has two unrelated documents. A fade can hide the replacement, but it cannot communicate continuity.
Name the smallest meaningful boundary
The named element should be large enough to preserve the object and small enough to avoid capturing unrelated layout. Here the surface owns geometry, clipping and background, so it is the correct transition boundary.
The name must be unique in the rendered document. Duplicate names can make the transition skip because the browser cannot establish a one to one mapping.
.surface {
view-transition-name: one-shape;
}
::view-transition-old(one-shape),
::view-transition-new(one-shape) {
animation-duration: 560ms;
animation-timing-function: cubic-bezier(.16, 1, .3, 1);
}Separate interface state from playback state
The surface can become compact while the track keeps playing. The album can open while progress continues. These behaviors work because visual state and playback state are independent.
Coupling them would produce accidental behavior. Collapsing the player might pause the track. Opening album detail might reset progress. A transition should describe presentation, not redefine product state.
interface InterfaceState {
visual: 'compact' | 'player' | 'album';
playback: 'playing' | 'paused' | 'ended';
progress: number;
}
// A visual transition changes only state.visual.
// Playback and progress continue independently.Changing visual must not mutate playback or progress.
The transition is optional. The state change is not.
The component checks both browser support and the user motion preference. If either condition prevents animation, the same update callback runs immediately. There is only one source of truth for the state change.
- 01
Only the active state is exposed to assistive technology.
- 02
If a focused control disappears, focus moves to the first useful control in the destination state.
- 03
Every icon button has a task oriented accessible name.
- 04
Reduced motion bypasses the snapshots but preserves the exact DOM update.
What usually breaks continuity
Continuity usually breaks when the browser cannot establish a stable one to one mapping. If two visible elements share the same transition name, identity becomes ambiguous. A broad crossfade creates a different version of the same problem: both interfaces remain perceptually present, so text and controls appear as ghosts instead of one surface changing shape.
Focus and state need the same discipline. A control that disappears while still focused leaves keyboard users in hidden UI. When the product waits for an animation callback before committing state, motion becomes the source of truth. The DOM should change synchronously. Animation only explains that change.
