Web Development

The View Transitions API in Practice: Animating Between Pages Without a Framework

A working page transition built with the native View Transitions API and about ten lines of CSS, plus the specific browser-support gotcha that meant shipping a graceful fallback, not a polyfill.

By Aissam Ait Ahmed Web Development 0 comments

Smooth page-to-page animation used to mean either a full SPA framework with client-side routing, or a pile of custom JavaScript faking a transition between two server-rendered pages. The View Transitions API does a version of this natively, with real browser support, on a plain multi-page site — no framework required. This is a working example on a small blog card grid, along with the actual gotcha that meant shipping a graceful fallback rather than assuming universal support.

What it actually does, without the marketing framing

When a view transition is triggered, the browser takes a screenshot-like snapshot of the page before the change and another after, then automatically cross-fades and morphs between them — and if you've tagged specific elements with a shared view-transition-name, the browser animates that specific element smoothly from its old position/size to its new one, rather than just cross-fading the whole page as one blob.

The simplest version: same-document transitions

For content that changes within a single page (no navigation), wrapping a DOM update in document.startViewTransition() is close to the entire API:

function filterCards(category) {
  document.startViewTransition(() => {
    document.querySelectorAll('.card').forEach(card => {
      card.style.display = card.dataset.category === category ? '' : 'none';
    });
  });
}

Calling this instead of just mutating the DOM directly gets an automatic cross-fade animation for free — no keyframes, no manual opacity tweening, no library. This alone was a meaningful upgrade over the site's previous filter interaction, which just snapped instantly with no transition at all, and it required changing exactly one line of the existing filter function rather than rewriting the interaction from scratch.

The more interesting case: cross-document transitions between real page loads

The genuinely new capability, shipped more recently than the same-document version, is a real cross-document transition — animating between two separate, full page navigations, the kind a plain multi-page site actually does. Enabling it needs one CSS declaration on both the origin and destination pages:

@view-transition {
  navigation: auto;
}

With that in place, a normal link click navigating from the blog index to a post detail page gets an automatic cross-fade transition, with zero JavaScript involved — the browser handles the entire snapshot-and-animate sequence around a real, full navigation.

Naming elements for a morphing transition, not just a cross-fade

The more visually satisfying effect — a blog post's thumbnail image smoothly growing into the full hero image on the detail page, rather than the whole page just cross-fading — needs matching view-transition-name values on the corresponding elements on both pages:

/* On the index page's card thumbnail */
.card[data-post-id="42"] img {
  view-transition-name: post-image-42;
}

/* On post 42's detail page hero image */
.post-hero img {
  view-transition-name: post-image-42;
}

When the browser sees a matching view-transition-name present in both the old and new page state, it animates that specific element's position and size directly between the two, instead of treating it as part of the generic whole-page cross-fade. The name has to be generated dynamically per post (as shown above, keyed by post ID) — a hardcoded static name would incorrectly try to morph between unrelated images if two different posts were navigated between in sequence, and worse, the browser would either silently pick one arbitrarily or, in stricter implementations, simply refuse to run the named transition at all the moment it detects the same name used more than once on the same page at the same time.

Customizing the animation beyond the default cross-fade

::view-transition-old(post-image-42),
::view-transition-new(post-image-42) {
  animation-duration: 0.4s;
  animation-timing-function: ease-out;
}

The browser generates pseudo-elements (::view-transition-old and ::view-transition-new) for each named transition, which are just normal CSS animation targets — meaning duration, easing, and even swapping the default cross-fade for a custom keyframe animation are all plain CSS, not a special API surface to learn separately.

Controlling which elements participate in a same-document transition

By default, document.startViewTransition() captures the entire page as a single before/after snapshot pair, which is fine for a simple cross-fade but can look wrong when only a small part of the page actually changed — the whole viewport briefly re-renders even for a small, localized update. Scoping which elements participate more precisely means giving the elements that should animate independently their own view-transition-name, exactly like the cross-document case above, even within a single-page update:

.card {
  view-transition-name: none; /* default: not individually named */
}

.card.is-expanding {
  view-transition-name: expanding-card;
}

Toggling that class onto exactly one card immediately before calling startViewTransition() means only that card gets its own independent transition treatment, while the rest of the page participates in the default root transition — a meaningfully different, more polished effect than treating every update as a full-page cross-fade regardless of how localized the actual visual change is.

A real interaction this fixed: a "load more" button that used to jump

The card grid's "load more" button previously caused a jarring layout jump the instant new cards appeared — the browser reflowed the grid instantly, and anything below the fold shifted position with zero visual continuity. Wrapping the DOM insertion in a view transition turned that instant, jarring reflow into cards smoothly fading and sliding into their new positions, with genuinely zero custom animation code beyond the wrapping call itself:

async function loadMoreCards() {
  const newCards = await fetchNextPage();

  document.startViewTransition(() => {
    cardContainer.insertAdjacentHTML('beforeend', newCards);
  });
}

This is a good example of where the API earns its simplicity argument concretely: the equivalent hand-rolled version — measuring old positions with getBoundingClientRect(), inserting the new content, measuring new positions, then animating the delta with FLIP-style JavaScript — is a meaningfully larger amount of code to write and maintain for a comparable visual result, and it's exactly the kind of small-but-fiddly animation logic that tends to accumulate edge-case bugs over time as the surrounding layout changes in ways the original FLIP implementation didn't anticipate.

Handling a transition interrupted mid-animation

A real edge case worth testing deliberately: what happens if a user clicks rapidly through several "load more" actions before the previous transition has finished animating. document.startViewTransition() returns a transition object with a ready promise and a finished promise, and calling it again before the previous one has finished doesn't queue politely by default — it can produce a visually abrupt interruption if not handled. Guarding against this with a simple in-flight flag kept the interaction feeling intentional rather than glitchy under rapid clicking:

let transitionInFlight = false;

async function loadMoreCards() {
  if (transitionInFlight) return;
  transitionInFlight = true;

  const newCards = await fetchNextPage();
  const transition = document.startViewTransition(() => {
    cardContainer.insertAdjacentHTML('beforeend', newCards);
  });

  await transition.finished;
  transitionInFlight = false;
}

This is a small addition, but it's the kind of edge case that's invisible in a slow, deliberate manual test and only shows up once a real user interacts with the page faster than the animation was designed to keep up with — worth testing by deliberately clicking too fast, not just clicking once and waiting.

The gotcha: this needs a fallback, not a polyfill

Browser support for the View Transitions API, especially the cross-document version, isn't universal yet — Chromium-based browsers support it, and support elsewhere has been catching up but isn't guaranteed for every visitor. The critical detail: there's no meaningful polyfill for this API, because faithfully replicating "the browser takes a live snapshot of the rendered page" in JavaScript isn't practically achievable. The correct approach isn't polyfilling, it's a clean fallback — because the API is additive by design, a browser that doesn't support it just performs a normal, instant navigation with no animation at all, no errors, no broken layout.

@supports not (view-transition-name: none) {
  /* Optional: nothing needed here at all in most cases —
     unsupported browsers simply skip the transition and
     navigate normally, which is a perfectly fine fallback */
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

The prefers-reduced-motion block matters more than the browser-support fallback in practice — respecting a visitor's OS-level reduced-motion preference is an accessibility requirement, not an optional nicety, and it's easy to ship a delightful transition effect while forgetting that some visitors have explicitly asked their system to minimize exactly this kind of animation. Testing this specific case is simple enough that there's no excuse to skip it: most operating systems expose a "reduce motion" toggle in their accessibility settings, and flipping it on while reloading the page during development takes seconds, yet it's one of the most commonly skipped checks on animation work generally, not just view transitions specifically.

What broke in testing, and the actual fix

The first real-world test against the blog's actual template revealed a layout shift during the transition on posts with a featured image of a different aspect ratio than the thumbnail — the morph animation stretched the image awkwardly mid-transition because the two elements' object-fit values didn't match between the card and the hero. Matching object-fit: cover consistently on both the thumbnail and hero image elements fixed the awkward stretch, which is a good general rule for this API: elements sharing a view-transition-name should share their fundamental sizing behavior too, or the browser's automatic interpolation between two differently-behaving elements looks visibly wrong mid-animation.

A short checklist before shipping a view transition

  • Confirm a prefers-reduced-motion override is in place — this isn't optional polish, it's respecting an explicit accessibility preference some visitors have deliberately set.
  • Verify the fallback in unsupported browsers is a clean, instant navigation, not a broken or half-styled page — the additive design of this API makes that the default, but it's worth actually testing in a non-Chromium browser rather than assuming.
  • Match object-fit and other fundamental sizing properties between any two elements sharing a view-transition-name, or the morph animation will visibly stretch or distort mid-transition.
  • Keep transition durations short — under half a second in most cases — since a page transition that feels sluggish makes navigation feel slower even when the underlying page load time hasn't changed at all.

Where this fits alongside performance work

A page transition effect is purely additive polish and shouldn't come at the cost of the fundamentals — a beautifully animated transition into a page with a slow Largest Contentful Paint is still a slow page, just a prettier one while it loads, and a visitor experiencing that slow load for the first time won't credit the smooth animation for making the wait feel any shorter than it genuinely, measurably actually was. If Core Web Vitals work hasn't happened yet, that's the higher-leverage place to start, covered in more depth in what actually moved our Core Web Vitals — and unoptimized hero images specifically are exactly the kind of asset this transition effect draws extra visual attention to, so running them through an image compressor first matters more, not less, once a smooth transition is drawing the eye toward that image during every single navigation.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools