More Animations Snippets
View Transitions API Gallery — HTML CSS JS Snippet
View Transitions Gallery · Animations · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
View Transitions Gallery — Shared-Element Morph with document.startViewTransition, view-transition-name & Pseudo-Element Animation Control

The shared-element transition — a thumbnail that grows into a detail hero as you navigate — used to require FLIP libraries (Framer Motion's layoutId, GSAP Flip) doing getBoundingClientRect math and transform interpolation by hand. The View Transitions API moved that entire job into the browser: you mutate the DOM inside document.startViewTransition(), tag the elements that should morph with a view-transition-name, and the engine captures before/after snapshots and animates position, size, and border-radius between them. This snippet is a complete, working grid-to-detail gallery built on the native API with a graceful fallback — the demo everyone searches for when they first hear about view transitions.
How the API actually works: snapshot, mutate, animate
Calling document.startViewTransition(callback) does three things. First, the browser screenshots the current page — both the full page and a separate snapshot for every element carrying a view-transition-name. Second, it runs your callback, which synchronously mutates the DOM into the new state (here: hide the grid, unhide the detail view). Third, it builds a pseudo-element tree — ::view-transition-old(name) and ::view-transition-new(name) inside a ::view-transition-group(name) — and animates between the snapshots: unnamed content cross-fades as root, while each named group *morphs*, with the browser interpolating the group's transform and size from the old element's box to the new element's box. The morph you see is not your elements moving; it is the browser animating screenshots of them.
The dynamic-naming trick for grids
The subtlety every real implementation hits: view-transition-name must be *unique in the DOM at snapshot time*. A grid of six thumbnails cannot all be named hero. The solution used here — and in production apps — is dynamic tagging: the name lives in a class (.vt-hero { view-transition-name: hero }) applied to the clicked thumbnail *just before* the transition starts, so the old snapshot captures it; inside the callback the class moves to the detail hero, so the new snapshot captures that instead. One name, two moments, two elements — the browser matches them and morphs. On close, the tagging reverses, and a cleanup timeout strips stray classes after the animation so no future transition ever finds two elements sharing a name (which would cancel the transition with a console error). The title text is tagged the same way with a second name, so the caption morphs alongside the artwork.
Styling the transition from CSS
Because the animation runs on pseudo-elements, you customise it in CSS, not JS: ::view-transition-group(hero) { animation-duration: 0.45s; animation-timing-function: cubic-bezier(0.4,0,0.2,1) } slows the morph enough to read, and ::view-transition-old(root)/::view-transition-new(root) tune the page cross-fade independently. This split is the API's quiet superpower — motion design lives entirely in the stylesheet. The snippet also includes the accessibility requirement most demos skip: a prefers-reduced-motion block that disables all transition animations for users who ask for that.
Progressive enhancement in one function
All API access routes through a five-line withTransition(mutate) helper: if document.startViewTransition exists, wrap the mutation; if not, run the mutation directly and show a support note. The app is fully functional either way — views still switch, clicks still work — which is exactly how this API is meant to be adopted: as pure enhancement. Chrome, Edge, and Safari support same-document view transitions today; the helper means Firefox users simply get instant switches until support lands. The same callback-wrapping pattern scales to SPA routers, and the cross-document variant (@view-transition { navigation: auto }) brings the identical morphs to full page navigations on MPA sites.
Build with AI
Build, Understand, Optimize, and Extend It With AI
The View Transitions API is small enough that an AI assistant can teach it to you through this one file: paste the snippet into Claude and ask it to trace a single click — what the browser snapshots when startViewTransition is called, why the vt-hero class is added before the call but moved inside the callback, and what would visibly break if you swapped that order (then actually swap it and watch). Once the model is clear, put the assistant to work on your real codebase: ask it to wire withTransition() into your router — flushSync in React, withViewTransitions() in Angular, or @view-transition CSS for an MPA — and to convert this demo's single-hero naming into per-item names for a list where several elements morph at once. Ask for taste, too: have it propose duration/easing pairs for the group animation and generate the reduced-motion and fallback variants for your browser-support matrix. Native API demos age well; the assistant's job is mapping this one onto your stack.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a grid-to-detail gallery using the native View Transitions API in plain HTML, CSS, and JavaScript — a shared-element morph with no animation libraries and no FLIP math.
Requirements:
- A 3-column grid of six cards, each with a gradient-filled square thumbnail and a caption, plus a hidden detail view containing a back button, a 16:9 hero, a title, a subtitle, and body copy; clicking a card switches to the detail view and Back returns to the grid.
- Route every view switch through a small helper that wraps the DOM mutation in document.startViewTransition when the API exists and runs the mutation directly otherwise, so the app is fully functional in unsupported browsers (and shows a small notice there).
- Implement the shared-element morph with dynamic naming: a CSS class carrying view-transition-name is added to the clicked thumbnail immediately before starting the transition, then inside the mutation callback the class is removed from the thumbnail and added to the detail hero — so the browser captures the thumbnail as the old state and the hero as the new state and morphs position, size, and border-radius between them automatically.
- Morph the caption into the detail title the same way with a second transition name, reverse both morphs on Back so the hero returns to the exact originating grid cell, and clean up all transition-name classes after the animation completes so no two elements can ever share a name (which would cancel transitions).
- Style the motion entirely in CSS: slow the named ::view-transition-group animations to ~0.45s with a smooth cubic-bezier, tune the root cross-fade duration separately, and include a prefers-reduced-motion block that disables all view-transition animations.
- Comment the code explaining the snapshot-mutate-animate lifecycle: why the mutation must happen inside the callback, what the browser pairs by name, and where an SPA router or cross-document @view-transition rule would slot into the same pattern.Step by step
How to Use
- 1Click through the morphClick any thumbnail: it morphs into the 16:9 detail hero — position, size, and corner radius animating together — while the caption glides into the title position and the rest of the page cross-fades. Click Back and the morph reverses into the exact grid cell it came from. In a browser without the API (Firefox), a notice appears and views switch instantly — the demo of graceful degradation is part of the demo.
- 2Understand the three moving partsEverything reduces to: (1) the .vt-hero class carrying view-transition-name: hero, (2) the withTransition() helper wrapping DOM mutations in document.startViewTransition when available, and (3) the tag-before/retag-inside choreography in openDetail() — tag the clicked thumbnail, then inside the callback move the tag to the detail hero. Read those ~30 lines and you understand the entire API surface this pattern needs.
- 3Swap in real imagesReplace the gradient divs with <img> elements: give the thumbnail and hero the same src (or srcset variants of the same photo) and keep the classes identical. Images morph exactly like gradients since the browser animates snapshots. For different crops between views, object-fit: cover on both keeps the morph clean; wildly different aspect ratios morph better if you also set overflow: hidden via ::view-transition-group styling.
- 4Tune the motion in CSS onlyAdjust ::view-transition-group(hero) animation-duration (0.3–0.5s reads best for morphs) and easing. For a springier feel, use cubic-bezier(0.34, 1.3, 0.64, 1). To animate the incoming detail text separately — slide up instead of cross-fade — give .detail-body its own view-transition-name and define @keyframes on ::view-transition-new(body-text). Never touch JavaScript for motion changes; that separation is the point of the API.
- 5Use it for real navigationIn an SPA, wrap your router's DOM swap: document.startViewTransition(() => router.render(newRoute)). In Next.js App Router, the experimental ViewTransition support or a startViewTransition call around router.push in a transition hook achieves the same. For classic multi-page sites, add @view-transition { navigation: auto } in CSS on both pages and matching view-transition-name values — same-origin navigations then morph with zero JavaScript at all.
- 6Export and composeClick JSX for a React version — keep withTransition() and call it around your setState that flips views (flushSync inside the callback ensures the DOM mutates synchronously). Pairs naturally with the Photo Gallery as the grid source, the Image Lightbox as an alternative detail treatment, and the Hover Expand Gallery for non-navigational expansion.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
At snapshot time the browser pairs elements by name: the old-state element named "hero" morphs into the new-state element named "hero". If two elements share a name in the same state, the pairing is ambiguous, so the browser skips the transition entirely and logs a console error — the single most common reason view transitions "silently don't work" in grids and lists. This snippet's solution is temporal uniqueness: the name lives in a class that is applied to exactly one element at a time — added to the clicked thumbnail before startViewTransition (so the old snapshot sees it), moved to the detail hero inside the mutation callback (so the new snapshot sees it), and stripped from everything by a cleanup timeout after the animation. The alternative for lists where many items morph simultaneously is per-item names (view-transition-name: card-42, set via inline style from the item id), but for one-at-a-time hero morphs the single moving class is simpler and collision-proof.
The withTransition() helper feature-detects document.startViewTransition. When absent, it runs the exact same DOM mutation immediately — the grid hides, the detail shows, everything functions — and reveals a small notice explaining that transitions are unavailable (in your own product you would omit the notice; it exists here because the fallback behaviour is part of what the demo teaches). This is the progressive-enhancement adoption model the API was designed for: no user is blocked, supporting browsers get the polish. As of 2026, same-document transitions work in Chrome, Edge, Safari, and Samsung Internet; Firefox has the implementation in progress. Never gate functionality on the API — only motion.
The API's contract is: old snapshot → your callback mutates → new snapshot → animate. The browser captures the "old" screenshots synchronously when you call startViewTransition, then invokes your callback (awaiting it if it returns a promise), then captures the "new" state from whatever the DOM looks like afterwards. If you mutate before calling, both snapshots show the new state and nothing morphs; if you mutate after (asynchronously, outside the callback's promise), the new snapshot misses your changes and the result flashes. In React this is why flushSync is needed inside the callback — setState alone is asynchronous, so React must be forced to commit the DOM changes before the callback resolves. The callback may return a promise for async work (data fetching before render), and the page freezes interaction between snapshots, which is why work inside should stay under ~100ms.
Tailwind: layout classes map directly (grid grid-cols-3 gap-3, aspect-square rounded-2xl, aspect-video rounded-3xl), and Tailwind ships arbitrary-property support for the names — [view-transition-name:hero] as a class replaces .vt-hero — but the ::view-transition-* pseudo-element rules must live in your global CSS since Tailwind has no utilities for them. React: keep withTransition, and inside it call flushSync(() => setView("detail")) so the DOM commit happens within the callback; React's experimental <ViewTransition> component and Next.js integration wrap this same mechanic if you prefer declarative naming. Angular: the router has first-class support — provideRouter(routes, withViewTransitions()) wraps every navigation in startViewTransition automatically, so you only add the transition-name classes and the CSS; for non-router state changes, call the API manually and mutate inside ApplicationRef.tick() or a signal update wrapped in the callback.