{"id":"l-css-4-2","courseId":"css-mastery","lang":"en","kind":"lesson","summary":"Animate between two states of the same page with document.startViewTransition(), give elements names so they morph into their new positions, and customise or disable the animation in CSS.","html":"<p>When a Waypoint visitor ticks “Hide sold-out”, three cards vanish and the rest jump upwards to fill the gaps. It’s instant and correct, and it’s disorienting: your eye was on the fourth card, and now something else is there. A smooth movement from the old layout to the new one would tell the eye where things went.</p>\n<p>Animating layout changes used to mean measuring every element before and after and running FLIP animations in JavaScript. The View Transition API does the measuring for you.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Snapshot, update, animate</h2>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">filters.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> button</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#hide-full'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> applyFilter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> hide</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> button.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-pressed'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!==</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'true'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  button.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-pressed'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">String</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(hide));</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> card</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> of</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelectorAll</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'.session-card.is-full'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    card.hidden </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> hide;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">button.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'click'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">document.startViewTransition) {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    applyFilter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();          </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// older browsers: just update</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">startViewTransition</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> applyFilter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">());</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p><code dir=\"ltr\">startViewTransition()</code> does four things in order:</p>\n<ol>\n<li>Captures a snapshot of the current page.</li>\n<li>Calls your update function, which changes the DOM however it likes.</li>\n<li>Captures the new state.</li>\n<li>Builds a tree of pseudo-elements showing old and new snapshots, and animates between them.</li>\n</ol>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 700 200\" role=\"img\" aria-labelledby=\"l-css-4-2-t1\"><defs><marker id=\"arrow-l-css-4-2-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-css-4-2-t1\">Four steps in a row connected by arrows: capture old snapshot, run the update callback, capture new snapshot, animate the pseudo-element tree from old to new.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"50\" width=\"150\" height=\"70\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"30\" y=\"80\">1. Capture</text>\n  <text class=\"d-label-muted\" x=\"30\" y=\"102\">old state</text>\n  <rect class=\"d-box-primary\" x=\"186\" y=\"50\" width=\"150\" height=\"70\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"206\" y=\"80\">2. Update</text>\n  <text class=\"d-code\" x=\"206\" y=\"102\">applyFilter()</text>\n  <rect class=\"d-box\" x=\"362\" y=\"50\" width=\"150\" height=\"70\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"382\" y=\"80\">3. Capture</text>\n  <text class=\"d-label-muted\" x=\"382\" y=\"102\">new state</text>\n  <rect class=\"d-box-accent\" x=\"538\" y=\"50\" width=\"150\" height=\"70\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"558\" y=\"80\">4. Animate</text>\n  <text class=\"d-code\" x=\"558\" y=\"102\">::view-transition</text>\n  <path class=\"d-arrow\" d=\"M160 85 L182 85\" marker-end=\"url(#arrow-l-css-4-2-1)\"/>\n  <path class=\"d-arrow\" d=\"M336 85 L358 85\" marker-end=\"url(#arrow-l-css-4-2-1)\"/>\n  <path class=\"d-arrow\" d=\"M512 85 L534 85\" marker-end=\"url(#arrow-l-css-4-2-1)\"/>\n  <text class=\"d-label-muted\" x=\"10\" y=\"170\">The real DOM is already in its new state while step 4 plays.</text>\n</svg><figcaption>A view transition captures the old state, runs the update, captures the new state, then animates</figcaption></figure>\n<p>The default animation is a crossfade of the whole page. It’s subtle, and already better than a jump. The DOM update itself is real and immediate; only the <em>picture</em> animates, and while it plays, the page underneath is already in its new state. If the update throws or the transition is skipped, the DOM change still happens.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Name elements to make them move</h2>\n<p>To make each card glide to its new position, give it a <code dir=\"ltr\">view-transition-name</code>. Every named element gets its own snapshot pair, and the browser animates its position and size from old to new:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.session-card</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  view-transition-name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--vt-name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// Give every card a unique name once, when the list renders</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> card</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> of</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelectorAll</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'.session-card'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  card.style.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setProperty</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'--vt-name'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">`card-${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">card</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">id</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">}`</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>Names must be <strong>unique</strong> among the elements rendered at the moment of capture. The cards that disappear fade out (they exist only in the old snapshot), and the cards that remain slide up into the gaps. Your eye follows them.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Reusing one name for a list</span></p>\n<p><code dir=\"ltr\">.session-card { view-transition-name: card; }</code> gives every card the same name. With duplicates the browser can’t pair snapshots, so it skips the whole transition and the page changes instantly, with a console error about duplicate names. Generate a unique name per element, from its id or index.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Styling the animation</h2>\n<p>The animation lives in a tree of pseudo-elements: <code dir=\"ltr\">::view-transition</code> at the top, a <code dir=\"ltr\">::view-transition-group(name)</code> per named element (plus <code dir=\"ltr\">root</code> for the rest of the page), and inside each, <code dir=\"ltr\">::view-transition-old(name)</code> and <code dir=\"ltr\">::view-transition-new(name)</code>. They’re animated with ordinary CSS animations, so you control them with ordinary CSS:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">::view-transition-group(</span><span style=\"color:#116329;--shiki-dark:#7EE787\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation-duration</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">250</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ms</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  animation-timing-function</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">ease-out</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">/* Keep the header still while the list moves */</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">.site-header</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">view-transition-name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: header; }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">::view-transition-group(</span><span style=\"color:#116329;--shiki-dark:#7EE787\">header</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">animation</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">none</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">; }</span></span></code></pre></div>\n<p>The <code dir=\"ltr\">*</code> matches every group. For reduced motion, remove the animations so the change is instant:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@media</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (prefers-reduced-motion: reduce) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ::view-transition-group(</span><span style=\"color:#116329;--shiki-dark:#7EE787\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">),</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ::view-transition-old(</span><span style=\"color:#116329;--shiki-dark:#7EE787\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">),</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ::view-transition-new(</span><span style=\"color:#116329;--shiki-dark:#7EE787\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    animation</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">none</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> !important</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>The <code dir=\"ltr\">!important</code> isn’t there to beat the browser’s default animations; your normal rules already win over those, as you saw in the first lesson. It’s there so the user’s preference also beats any more specific animation you or a library wrote for one named group, such as <code dir=\"ltr\">::view-transition-group(header)</code>.</p>\n<p>You can also target one direction of change. The old snapshot of a card that disappears only has a <code dir=\"ltr\">::view-transition-old(card-s2)</code>, with no new counterpart, so a rule like <code dir=\"ltr\">::view-transition-old(*) { animation-duration: 150ms; }</code> makes leaving cards fade a little faster than the remaining ones move. Small differences like that make a list feel organised rather than shuffled.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Waiting for the transition</h2>\n<p><code dir=\"ltr\">startViewTransition()</code> returns a <code dir=\"ltr\">ViewTransition</code> object with three promises. <code dir=\"ltr\">updateCallbackDone</code> resolves when your DOM update has run, <code dir=\"ltr\">ready</code> when the animation is about to start, and <code dir=\"ltr\">finished</code> when it has ended and the pseudo-elements are gone. Use <code dir=\"ltr\">finished</code> to move focus or announce a result after the motion settles, for example sending focus to the first remaining card for keyboard users. Don’t make anything essential wait on the animation, though: in a browser without the API, there’s no transition object at all.</p>\n<aside class=\"callout callout-note\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg><span><span class=\"callout-kind\">Note</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Support</span></p>\n<p>Same-document view transitions became Baseline newly available in October 2025, when Firefox 144 shipped them. Cross-document transitions between pages of a multi-page site (opted into with <code dir=\"ltr\">@view-transition { navigation: auto; }</code>) work in Chromium and Safari but not yet in Firefox, so treat them as an enhancement. Either way, a browser without support just updates the page, which is why the feature check in the click handler matters.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">Your turn</h2>\n<p>The exercise’s filter button hides sold-out sessions with no animation. Give each card a unique name, wrap the update in a view transition with a fallback, set the group duration to 250ms, and switch the animation off for reduced motion. The checks spy on <code dir=\"ltr\">startViewTransition</code> and also remove it to make sure your fallback works. Next, you’ll tie animation progress to scrolling instead of time.</p>\n","toc":[{"id":"sec-1","text":"Snapshot, update, animate","level":2},{"id":"sec-2","text":"Name elements to make them move","level":2},{"id":"sec-3","text":"Styling the animation","level":2},{"id":"sec-4","text":"Waiting for the transition","level":2},{"id":"sec-5","text":"Your turn","level":2}],"takeaways":["<code dir=\"ltr\">document.startViewTransition(update)</code> snapshots the page, runs your DOM update, snapshots again and animates between the two with a crossfade by default.","An element with a unique <code dir=\"ltr\">view-transition-name</code> gets its own snapshot pair, so it moves and resizes smoothly to its new position instead of fading.","The animation is built from pseudo-elements such as <code dir=\"ltr\">::view-transition-group(name)</code>, which you style with ordinary CSS animations.","Always feature-detect <code dir=\"ltr\">startViewTransition</code> and run the update directly when it’s missing, and remove the animation for users who prefer reduced motion."],"further":[{"title":"View Transition API (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API"},{"title":"Document: startViewTransition() method (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition"},{"title":"Same-document view transitions have become Baseline Newly available (web.dev)","url":"https://web.dev/blog/same-document-view-transitions-are-now-baseline-newly-available"}],"quiz":[{"q":"<p>You call <code dir=\"ltr\">document.startViewTransition(() =&gt; applyFilter())</code> with no CSS at all. What does the user see?</p>","options":[{"text":"Nothing; view transitions need a <code dir=\"ltr\">view-transition-name</code> before they animate.","why":"Without names, the whole page is captured as <code dir=\"ltr\">root</code> and still animates."},{"text":"The filter applies instantly, because the default animation duration is zero.","why":"The default is a short crossfade, not an instant change."},{"text":"A quick crossfade of the whole page from the old state to the new one.","why":"Correct. The root snapshot pair crossfades by default; names add per-element movement on top."}],"answer":2},{"q":"<p>Two cards on the page both have <code dir=\"ltr\">view-transition-name: card</code>. What happens when you start a transition?</p>","options":[{"text":"The transition is skipped, because names must be unique among rendered elements.","why":"Correct. Duplicate names make the browser abort the animation; the DOM update still runs, so the page just changes instantly."},{"text":"Both cards animate together as one group.","why":"A group has exactly one old and one new snapshot; the browser can’t merge two elements into one."},{"text":"Only the first card in the DOM animates.","why":"The browser doesn’t pick a winner; the duplicate makes the whole transition invalid."},{"text":"The second card gets a generated name automatically.","why":"With an explicit duplicate name, nothing is generated; the transition is skipped."}],"answer":0},{"q":"<p>How do you make every view transition on the page take 250ms?</p>","options":[{"text":"<code dir=\"ltr\">document.startViewTransition({ duration: 250 })</code>","why":"The method takes an update callback (or an options object with <code dir=\"ltr\">update</code> and <code dir=\"ltr\">types</code>), not a duration."},{"text":"<code dir=\"ltr\">::view-transition-group(*) { animation-duration: 250ms; }</code>","why":"Correct. The groups run CSS animations, so ordinary animation properties control their timing."},{"text":"<code dir=\"ltr\">view-transition-duration: 250ms</code> on <code dir=\"ltr\">:root</code>","why":"There’s no such property; timing comes from the animations on the pseudo-elements."}],"answer":1},{"q":"<p>Which statement about support is accurate in 2026?</p>","options":[{"text":"Same-document and cross-document view transitions both work in every major browser.","why":"Cross-document view transitions for multi-page apps aren’t available in Firefox yet, so they’re not Baseline."},{"text":"View transitions only work in Chromium browsers.","why":"Safari and Firefox both support same-document view transitions now."},{"text":"View transitions require a framework router to work.","why":"They’re a browser API; any DOM update inside the callback works, with or without a framework."},{"text":"Same-document view transitions are Baseline since October 2025; cross-document ones aren’t Baseline yet.","why":"Correct. Firefox 144 completed same-document support; MPA (cross-document) transitions still lack Firefox support."}],"answer":3}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<button id=\"hide-full\" type=\"button\" aria-pressed=\"false\">Hide sold-out</button>\n<section class=\"schedule\">\n  <article class=\"session-card\" id=\"s1\">Shipping View Transitions</article>\n  <article class=\"session-card is-full\" id=\"s2\">Grid Layout Workshop · Sold out</article>\n  <article class=\"session-card\" id=\"s3\">Color Spaces for Humans</article>\n  <article class=\"session-card is-full\" id=\"s4\">Container Queries Lab · Sold out</article>\n  <article class=\"session-card\" id=\"s5\">The Cost of a Frame</article>\n</section>","css":".schedule { display: grid; gap: 8px; margin-top: 12px; }\n.session-card {\n  padding: 10px 14px;\n  border: 1px solid #e5e7eb;\n  border-radius: 10px;\n  background: white;\n}\n.session-card[hidden] { display: none; }\n","js":"const button = document.querySelector('#hide-full');\n\nfunction applyFilter() {\n  const hide = button.getAttribute('aria-pressed') !== 'true';\n  button.setAttribute('aria-pressed', String(hide));\n  for (const card of document.querySelectorAll('.session-card.is-full')) {\n    card.hidden = hide;\n  }\n}\n\nbutton.addEventListener('click', () => {\n  applyFilter();\n});\n"},"solution":{"html":"<button id=\"hide-full\" type=\"button\" aria-pressed=\"false\">Hide sold-out</button>\n<section class=\"schedule\">\n  <article class=\"session-card\" id=\"s1\">Shipping View Transitions</article>\n  <article class=\"session-card is-full\" id=\"s2\">Grid Layout Workshop · Sold out</article>\n  <article class=\"session-card\" id=\"s3\">Color Spaces for Humans</article>\n  <article class=\"session-card is-full\" id=\"s4\">Container Queries Lab · Sold out</article>\n  <article class=\"session-card\" id=\"s5\">The Cost of a Frame</article>\n</section>","css":".schedule { display: grid; gap: 8px; margin-top: 12px; }\n.session-card {\n  padding: 10px 14px;\n  border: 1px solid #e5e7eb;\n  border-radius: 10px;\n  background: white;\n}\n.session-card[hidden] { display: none; }\n.session-card {\n  view-transition-name: var(--vt-name);\n}\n\n::view-transition-group(*) {\n  animation-duration: 250ms;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  ::view-transition-group(*),\n  ::view-transition-old(*),\n  ::view-transition-new(*) {\n    animation: none !important;\n  }\n}\n","js":"const button = document.querySelector('#hide-full');\n\n// A unique view-transition-name per card\nfor (const card of document.querySelectorAll('.session-card')) {\n  card.style.setProperty('--vt-name', `card-${card.id}`);\n}\n\nfunction applyFilter() {\n  const hide = button.getAttribute('aria-pressed') !== 'true';\n  button.setAttribute('aria-pressed', String(hide));\n  for (const card of document.querySelectorAll('.session-card.is-full')) {\n    card.hidden = hide;\n  }\n}\n\nbutton.addEventListener('click', () => {\n  if (!document.startViewTransition) {\n    applyFilter();\n    return;\n  }\n  document.startViewTransition(() => applyFilter());\n});\n"},"checks":[{"id":"unique-names","test":"const names = $$('.session-card').map((c) => style(c, 'view-transition-name')); return names.every((n) => n && n !== 'none' && n !== 'auto') && new Set(names).size === names.length;"},{"id":"uses-view-transition","test":"const waitFor = async (fn) => { for (let i = 0; i < 60; i++) { if (fn()) return true; await tick(25); } return fn(); }; if (!document.startViewTransition) return false; const orig = document.startViewTransition; let called = false; document.startViewTransition = function (...args) { called = true; return orig.apply(document, args); }; try { await click('#hide-full'); await waitFor(() => $('#s2').hidden); } finally { document.startViewTransition = orig; } return called;"},{"id":"filter-works","test":"const waitFor = async (fn) => { for (let i = 0; i < 60; i++) { if (fn()) return true; await tick(25); } return fn(); }; return (await waitFor(() => $('#s2').hidden && $('#s4').hidden)) && !$('#s1').hidden && $('#hide-full').getAttribute('aria-pressed') === 'true';"},{"id":"fallback-works","test":"const waitFor = async (fn) => { for (let i = 0; i < 60; i++) { if (fn()) return true; await tick(25); } return fn(); }; const orig = document.startViewTransition; document.startViewTransition = undefined; let ok = false; try { await click('#hide-full'); ok = await waitFor(() => !$('#s2').hidden); } catch (e) { ok = false; } finally { document.startViewTransition = orig; } return ok;"},{"id":"group-duration","test":"const all = []; const walk = (rules, media) => { for (const r of rules) { all.push({ r, media }); if (r.cssRules) walk(r.cssRules, media || (r instanceof CSSMediaRule && r.conditionText.includes('prefers-reduced-motion'))); } }; for (const s of document.styleSheets) walk(s.cssRules, false); return all.some(({ r }) => r.selectorText && r.selectorText.includes('::view-transition-group') && (parseFloat(r.style.animationDuration) === 250 || parseFloat(r.style.animationDuration) === 0.25));"},{"id":"reduced-motion","test":"const all = []; const walk = (rules, media) => { for (const r of rules) { all.push({ r, media }); if (r.cssRules) walk(r.cssRules, media || (r instanceof CSSMediaRule && r.conditionText.includes('prefers-reduced-motion'))); } }; for (const s of document.styleSheets) walk(s.cssRules, false); return all.some(({ r, media }) => media && r.selectorText && r.selectorText.includes('::view-transition') && (r.style.animation || r.style.animationName));"}],"title":"Animate the filter with a view transition","prompt":"<p>Clicking <strong>Hide sold-out</strong> hides two cards instantly. Make the change animate:</p>\n<ol>\n<li>Give every <code dir=\"ltr\">.session-card</code> a <strong>unique</strong> <code dir=\"ltr\">view-transition-name</code> (in CSS, in JS, or both).</li>\n<li>Run the filter update inside <code dir=\"ltr\">document.startViewTransition()</code>, and fall back to a plain update when the method doesn’t exist.</li>\n<li>Make every <code dir=\"ltr\">::view-transition-group</code> animation last <strong>250ms</strong>.</li>\n<li>Turn the view-transition animations off for users who prefer reduced motion.</li>\n</ol>\n<p>The checks spy on <code dir=\"ltr\">document.startViewTransition</code> and also delete it to test your fallback.</p>\n","hints":["One CSS rule can read a per-card custom property: <code dir=\"ltr\">view-transition-name: var(--vt-name);</code>. Set <code dir=\"ltr\">--vt-name</code> per card from its id in JS.","In the click handler: <code dir=\"ltr\">if (!document.startViewTransition) { applyFilter(); return; }</code> then <code dir=\"ltr\">document.startViewTransition(() =&gt; applyFilter());</code>.","<code dir=\"ltr\">::view-transition-group(*) { animation-duration: 250ms; }</code>, and inside <code dir=\"ltr\">@media (prefers-reduced-motion: reduce)</code> set <code dir=\"ltr\">animation: none !important</code> on the group, old and new pseudo-elements."],"explanation":"<p>Each card now has a unique name (<code dir=\"ltr\">card-s1</code>, <code dir=\"ltr\">card-s2</code>…), so the browser pairs its old and new snapshots and slides the remaining cards into the gaps while the hidden ones fade out. A shared name would make the browser skip the transition. Wrapping <code dir=\"ltr\">applyFilter()</code> in <code dir=\"ltr\">startViewTransition()</code> gives the browser its before and after states; the feature check keeps the button working in browsers without the API. The group animations are normal CSS animations, so <code dir=\"ltr\">animation-duration</code> sets their timing and <code dir=\"ltr\">animation: none</code> inside the reduced-motion query turns them into an instant change.</p>\n","checkLabels":{"unique-names":"Every card has a unique view-transition-name","uses-view-transition":"The click runs the update inside startViewTransition()","filter-works":"Sold-out cards are hidden and the button state updates","fallback-works":"The button still works without the View Transition API","group-duration":"View transition groups last 250ms","reduced-motion":"The animation is removed for reduced motion"}},"words":729,"readMinutes":3}