vercel-react-view-transitions
Guide for implementing smooth, native-feeling animations using React's View Transition API (`<ViewTransition>` component, `addTransitionType`, and CSS view transition pseudo-elements). Use this skill whenever the user wants to add page transitions, animate route changes, create shared element animations, animate enter/exit of components, animate list reorder, implement directional (forward/back) navigation animations, or integrate view transitions in Next.js. Also use when the user mentions view
Install
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-view-transitionsPatterns and Guidelines
Use the official React <ViewTransition> reference for API mechanics. This file collects reusable implementation patterns and failure modes from production apps.
Searchable Grid with useDeferredValue
useDeferredValue makes filter updates a transition, activating <ViewTransition>:
Per-item <ViewTransition name={...}> inside a deferred list triggers cross-fades on every keystroke. Fix with default="none":
Card Expand/Collapse with startTransition
Toggle between grid and detail view with shared element morph:
Type-Safe Transition Helpers
Use as const arrays and derived types to prevent ID clashes:
Cross-Fade Without Remount
Omit key to trigger an update (cross-fade) instead of exit + enter. Avoids Suspense remount/refetch:
Use key when content identity changes (state resets). Omit for cross-fades (tabs, panels, carousel).
Isolate Elements from Parent Animations
Pull an element out of the animated root snapshot by giving it its own view-transition-name. view-transition-name: none is a no-op — it's the CSS default, so the element stays in root (a common flicker bug). Use a real, unique name, then neutralize with <ViewTransition default="none"> (no CSS) or CSS (needed for z-index/display control — see css-recipes.md [blocked]).
- Persistent chrome (nav, sidebar, player bar):
<nav style={{ viewTransitionName: 'persistent-nav' }}>+ isolation CSS.<ViewTransition default="none">works too, but its auto-name can't takez-index/backdropdisplay:none— hand-name when you need those. - Floating elements (popovers, menus): left open, they're captured in
rootand flicker on settle. Real name + isolation (Floating Element Isolation [blocked]). A static name is fine if only one is mounted (unmountOnHide); native top-layer (popover/<dialog>) settle-flicker is a browser limit. - Naming an interactive element has a cost: named participants are skipped by hit-testing while a transition runs (csswg#10930) — clicks and hover fall through to whatever is beneath. Portal named popovers/menus; rendered inline in a clickable row, mid-transition clicks activate the row and read as outside-clicks that close the popover.
- Third-party floating components (toast libraries, portals you don't render): put the name on an always-mounted wrapper you own —
<div style={{ viewTransitionName: 'toaster' }} className="pointer-events-none fixed inset-0">. Library containers often unmount when empty, so naming them directly leaves the group unpinned exactly when a toast appears mid-transition. Name a dialog's backdrop separately from its panel so each pins independently.
Suspense reveal flicker
An element rendered in both the fallback and the content flickers (opacity dip) on reveal — it fades against itself. Not a morph. Fix: render it outside the <Suspense> boundary (mount once, above it), or pin it with a view-transition-name.
Don't put a manual viewTransitionName on the root DOM node inside <ViewTransition> — React's auto-name overrides it.
Sliding Indicator (tabs)
One shared-name indicator rendered under the active tab morphs between positions on change (slide the group, disable old/new — see Sliding Indicator [blocked]). Render it only under the active tab so exactly one element holds indicatorName; use a distinct indicatorName per tab strip. Trigger the state change inside startTransition so the move animates. Whatever owns active drives it — local state here, routing in Next (see Routing-Driven Tabs [blocked]).
Because the state change is a transition, if the newly-active tab renders suspending content the whole update — indicator and aria-current — waits for it to commit, and the strip feels dead on click. Give the controls an immediate value with useOptimistic (drive aria-current from it) so feedback is instant while the content streams. The routing variant (Routing-Driven Tabs [blocked]) does exactly this: optimistic aria-current, committed active for the bar.
Layout Displacement Morph
Only content inside an activated boundary animates position — everything else teleports to its new layout spot. When a list grows or shrinks, wrap the sibling content below it so it glides instead of jumping:
The section — heading included — morphs as one group when rows above are added or removed. Nothing inside the section changed; the displacement is the update.
- React only measures boundaries that are direct children of nodes along the changed path — a VT buried under an extra wrapper element won't activate. Place the boundary as a direct sibling of the changing content.
- Sometimes the better fix is no morph at all: pad fixed-size lists to a constant slot count with invisible fillers so the grid never changes height and nothing below it moves.
default="none"disables exactly this morph — it turns offupdate. Named/shared elements getdefault="none"; displaced siblings and keyed list items stay bare or setupdate="auto".
Reusable Animated Collapse
Composing with Activity
Activity is orthogonal to view transitions: it preserves the state of a hidden subtree, ViewTransition animates it. Compose them for an in-page show/hide (drawer, panel, tab body) that keeps its scroll/form state while it animates in and out:
Only reach for Activity when there's state worth preserving — a stateless element (e.g. the sliding indicator above) gains nothing from it. In Next.js, layout-hosted chrome already persists across navigations without Activity (see nextjs.md [blocked]).
Exclude Elements with useOptimistic
useOptimistic values update before the transition snapshot, excluding them from animation. Use for controls (labels); use committed state for animated content:
View Transition Events
Imperative control via onEnter, onExit, onUpdate, onShare. Return a cleanup function to cancel your animation when the transition finishes. onShare takes precedence over onEnter/onExit.
The instance object: instance.old, instance.new, instance.group, instance.imagePair, instance.name.
The types array (second argument) lets you vary animation based on transition type.