view-transitions-contracts
Installation
SKILL.md
View transitions contracts
A view transition is a visual enhancement wrapped around a DOM change: the DOM update always happens, so a broken transition fails silently — no console error, just no animation (or a frozen frame). This lens spots the silent-abort, stale-snapshot, and reduced-motion bugs in review; it is not a guide for building transitions.
Checklist (lead with the trap)
- Duplicate
view-transition-nameon the same frame silently aborts the whole transition. If two rendered elements carry the same name at the same time,ViewTransition.readyrejects and the animation is skipped — but the DOM still updates, so it reads as "the animation randomly doesn't fire." This is the classic list bug: reusing one static name across items. Suffix each with a unique id/key (or useview-transition-name: match-elementwhere supported). - A frozen or stale old snapshot means the incoming DOM was not painted when the snapshot was taken. Snapshots are paint-based images, not live DOM. If the new view is still a Suspense fallback, a not-yet-streamed server component, or an image that has not decoded, the new snapshot captures the wrong/empty state and the old frame appears stuck. Hoist the matched (shared) element above the Suspense boundary so it exists on both sides; await image
decode()/ data readiness beforestartViewTransition. React<ViewTransition>waits for stylesheets and up to ~500ms for fonts, but not arbitrary data. prefers-reduced-motionis NOT honored automatically. The UA default cross-fade plus the group transform still run. Needs an explicit reduced-motion block on the pseudos, e.g.@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }(or gatestartViewTransitionin JS / passskipTransition). Note reduced does not mean none —animation: noneis an instant cut; a very short cross-fade is often the gentler choice. Missing this is an accessibility defect, not a nicety.- React: the state update must be inside a Transition, and
flushSyncopts you out. With<ViewTransition>, the mutation must run insidestartTransition— plainsetState,useSyncExternalStore, and updates after anawait/setTimeoutare not marked as Transitions and will not animate; a strayflushSyncmid-flow makes React skip the transition entirely. The vanilla API is the opposite: you wrap thesetStateinflushSyncinside thestartViewTransitioncallback so the DOM applies synchronously before the snapshot. Pick one pattern; do not mix them. - Only one transition runs at a time; a new one interrupts and skips the current.
skipTransition()and interruption cancel only the animation —updateCallbackand the DOM change still run. "It skipped" never means "the state did not update." Rapid navigations fast-forward to the end state (a visual jump); chain update callbacks into onestartViewTransitionif smoothness matters. - Leftover
view-transition-namecauses ghost animations. A dynamically-set name not cleared after the snapshot persists (including in the bfcache on back/forward), so an unrelated element morphs later, or a duplicate-name abort appears on the nextpagereveal. Remove names once the snapshot has been taken. - Fixed chrome and top-layer content can paint behind the overlay. A document-scoped transition paints the
::view-transitionlayer above everything (including the top layer), soposition: fixedheaders and popovers get baked into the flatrootsnapshot and slide/disappear. Give them their ownview-transition-nameplus a high::view-transition-group()z-index, or use an element-scoped transition. - iframe / cross-origin content cannot participate. Transitions are same-origin only; cross-document transitions are main-frame + same-origin with a matching
@view-transitionopt-in, and iframe content is not snapshotted (it reloads when moved). Do not promise a shared-element morph across an iframe or cross-origin boundary.
Quick probes
Use these as leads, then read the transition-to-snapshot path: