EmpireUI
Get Pro
← Blog8 min read#view transitions#css#cross-document

CSS View Transitions Advanced: Cross-Document, Custom Animations

Cross-document view transitions landed in Chrome 126 — here's how to build custom named animations, morph elements between pages, and avoid the common pitfalls.

abstract flowing gradient animation representing CSS page transitions

Where We Are With View Transitions in 2026

The View Transitions API shipped in Chrome 111 for same-document transitions, then cross-document support landed in Chrome 126 — and that second part is the one that actually changes how you build multi-page apps. You no longer need a single-page architecture, a routing library, or JavaScript-driven page swaps just to get polished animated navigations. The browser handles it natively, at the navigation level.

That said, most tutorials still cover the same-document case: wrap your DOM mutation in document.startViewTransition(), done. Cross-document is trickier. It's opt-in per page, it requires a same-origin navigation, and the animation authoring model is meaningfully different. This article focuses on the stuff that's actually hard — named element morphing, custom keyframes on the pseudo-elements, and what happens when you mix this with a framework like Next.js.

Worth noting: Firefox shipped cross-document view transitions in version 131 (late 2024), and Safari added it in 18.2. So as of mid-2026 you're looking at roughly 85% global coverage with no polyfill needed for the baseline fade. Custom animations need a @supports guard, but the feature itself isn't experimental anymore.

Honestly, the API design is unusual if you're used to JavaScript animation libraries. You're styling pseudo-elements (::view-transition-old(), ::view-transition-new()), not DOM nodes. That mental shift is the biggest hurdle.

Enabling Cross-Document Transitions: The Basics

You opt both pages into cross-document view transitions with a single @view-transition at-rule in your CSS. Both the outgoing and incoming page need it — if either page is missing it, the browser falls back to an instant swap.

/* Required on BOTH the old and new page */
@view-transition {
  navigation: auto;
}

That's the minimal setup. You get a default cross-fade that takes 250ms. Not bad for one line of CSS. But the default transition uses a single snapshot of the entire viewport — ::view-transition-old(root) fading out while ::view-transition-new(root) fades in. It works, but it looks like every other cross-fade ever.

To get anything more sophisticated — slide animations, hero image morphing, staggered content — you need named view transition elements and custom keyframes. That's where it gets interesting.

Quick aside: the navigation: auto value only captures navigations initiated by the user (link clicks, back/forward). Programmatic location.href assignments and form submissions are also captured. fetch() calls are not — those don't trigger a navigation event at all.

Named Elements and Morph Transitions

This is the genuinely powerful part. Give an element on the outgoing page and a matching element on the incoming page the same view-transition-name, and the browser automatically animates between them — position, size, shape, everything. It's the same trick iOS uses when you tap a photo and it flies to fill the screen.

/* On the article list page */
.article-card img {
  view-transition-name: hero-image;
}

/* On the article detail page */
.article-hero img {
  view-transition-name: hero-image;
}
/* The browser creates a ::view-transition-group(hero-image)
   that interpolates geometry between the two elements.
   You can override its timing: */
::view-transition-group(hero-image) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

A few constraints you'll hit immediately. First, view-transition-name must be unique per page — two elements with the same name on the same page throws an error and the transition aborts. Second, the element needs to be visible and painted at the moment the transition snapshot is taken. If your card image is lazy-loaded and hasn't rendered yet, the snapshot will be empty. Set loading="eager" on images you want to morph, or use content-visibility: auto carefully.

In practice, hero image morphing between a card grid and a detail page is the most impactful use case by far. Think of an e-commerce product grid where the product photo smoothly expands into the hero on the product page. It takes 10 lines of CSS and zero JavaScript. Pair that kind of navigation polish with a well-structured component library — the Empire UI's aurora or glassmorphism components give you the visual foundation to make those transitions actually *land*.

Writing Custom Keyframes for Transition Pseudo-Elements

The default cross-fade uses @keyframes -ua-view-transition-fade-in and -ua-view-transition-fade-out defined by the browser. You can override them entirely by targeting the pseudo-elements with your own animation declarations.

/* Slide content up from 16px below, fade in */
@keyframes slide-up-in {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
}

@keyframes slide-down-out {
  to {
    opacity: 0;
    transform: translateY(-16px);
  }
}

/* Apply to all content except the hero image */
::view-transition-old(root) {
  animation: 200ms ease-in both slide-down-out;
}

::view-transition-new(root) {
  animation: 300ms ease-out 100ms both slide-up-in;
}

The 100ms delay on the incoming animation matters. Without it, the old and new content overlap at full opacity for a frame, which looks like a flash. Staggering them — even by 50ms — eliminates that artifact. The both fill-mode is non-negotiable; drop it and you'll see the element snap to its natural position at the start or end of the animation.

You can go further and give specific page sections their own named transitions. Navigation bars often don't need to animate at all between sibling pages — set view-transition-name: none on your <nav> during the transition and it'll hold still while everything else moves. This is a 2px detail that makes the whole thing feel like a native app instead of a web page.

nav {
  view-transition-name: site-nav;
}

/* Hold nav perfectly still */
::view-transition-old(site-nav),
::view-transition-new(site-nav) {
  animation: none;
  mix-blend-mode: normal;
}

Look, the pseudo-element model is verbose. But it's also just CSS — you can author it in Tailwind's @layer blocks, in CSS Modules, or anywhere you write regular stylesheets. No runtime required.

Cross-Document Transitions in Next.js and React Frameworks

This is where developers get tripped up. Next.js App Router uses client-side navigation by default — links don't cause full page loads, they swap route segments in the React tree. That means @view-transition { navigation: auto } does nothing for Next.js internal navigation. The browser never sees a real navigation event.

For same-document transitions in Next.js you use document.startViewTransition() directly, wrapping the router push. The App Router doesn't expose a built-in hook for this yet as of Next.js 14, so you wire it up manually:

import { useRouter } from 'next/navigation';
import { useCallback } from 'react';

export function useViewTransitionRouter() {
  const router = useRouter();

  const push = useCallback(
    (href: string) => {
      if (!document.startViewTransition) {
        router.push(href);
        return;
      }
      document.startViewTransition(() => {
        router.push(href);
      });
    },
    [router]
  );

  return { push };
}

Cross-document transitions — the native CSS ones — only apply if you're running a multi-page app (MPA) or using Next.js with full page reloads, which you might be doing intentionally with export const dynamic = 'force-static' pages or a static export. In that scenario, the CSS approach works perfectly and you get zero-JS animated navigation.

That said, mixing client-side and server-rendered pages in the same Next.js app creates a frustrating inconsistency: some navigations get the cross-document transition, others hit the client router and get nothing. Pick a lane. The page-transitions-nextjs article on this blog covers the full framework integration in detail — worth reading before you commit to an approach.

Progressive Enhancement and the @supports Pattern

The baseline you can ship today without worrying about browser support is the @view-transition { navigation: auto } opt-in plus default cross-fade. That works in ~85% of browsers, degrades gracefully to instant navigation in the remaining 15%, and requires zero JavaScript.

Custom animations with named elements need a feature check. The cleanest approach is @supports (view-transition-name: test) — that property only exists in browsers that support the full named transitions spec:

@view-transition {
  navigation: auto;
}

/* Only apply named transitions if the browser supports them */
@supports (view-transition-name: none) {
  .card-image {
    view-transition-name: product-hero;
  }

  ::view-transition-group(product-hero) {
    animation-duration: 350ms;
  }

  ::view-transition-old(root) {
    animation: 180ms ease-in both fade-out-up;
  }

  ::view-transition-new(root) {
    animation: 280ms ease-out 80ms both fade-in-up;
  }
}

Always respect prefers-reduced-motion. The browser's default view transition animations already check this — they'll skip to an instant cut automatically. But your custom keyframes override that behavior, so you need to wrap them:

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root) {
    animation: 180ms ease-in both fade-out-up;
  }
  ::view-transition-new(root) {
    animation: 280ms ease-out 80ms both fade-in-up;
  }
}

One more thing — if you're already using GSAP or Framer Motion for scroll animations and other UI motion, view transitions sit cleanly alongside them. They operate at the navigation level, not the component animation level. You won't get conflicts. For a deeper look at choosing between CSS and JS animation tools, the framer-motion-vs-gsap comparison covers the decision well.

Debugging View Transitions in Chrome DevTools

Chrome DevTools added view transition debugging in version 126. Open the Animations panel (Ctrl/Cmd+Shift+P → "Show Animations"), trigger a navigation, and you'll see all the ::view-transition-* pseudo-element animations captured in the timeline. You can scrub through them, inspect their keyframes, and check timing.

The most common bug you'll encounter is a named transition element that doesn't morph — it just fades. This almost always means the element wasn't found with the same name on both pages, or it was below the fold and hadn't painted yet. The DevTools Elements panel will show you the ::view-transition-group pseudo-elements in the DOM tree during an active transition — if your named group shows up as a 0×0 rectangle, the snapshot was empty.

A second common issue: the transition fires but everything looks clipped or wrong on mobile. This is usually because you have overflow: hidden on a parent above the transitioning element. The view transition snapshot is taken of the painted output, and clipping affects what gets captured. Set overflow: clip instead — it preserves the clipping without creating a stacking context that breaks the snapshot.

For your design system components, you can tie these polished transitions into the same visual language as the rest of your UI. The gradient generator and box shadow generator tools are useful for generating the CSS you'd want on the incoming page's hero area — matching the transition animation to the destination's color palette makes the whole sequence feel cohesive rather than bolted-on.

FAQ

Do cross-document view transitions work with React and Next.js?

Not directly — Next.js uses client-side routing so the browser never fires a real navigation event. You need document.startViewTransition() wrapped around your router.push() calls for same-document transitions instead.

What's the difference between same-document and cross-document view transitions?

Same-document transitions are triggered via document.startViewTransition() in JavaScript, wrapping a DOM update. Cross-document transitions are purely CSS via @view-transition { navigation: auto } and fire automatically on real page navigations between same-origin URLs.

Can two elements have the same view-transition-name on the same page?

No — duplicate names on the same page abort the transition and the browser falls back to an instant swap. Each view-transition-name value must be unique within a single document at snapshot time.

Do view transitions affect accessibility or reduced-motion preferences?

The browser's default view transition animations already respect prefers-reduced-motion and skip to an instant cut. If you write custom keyframes, you must add your own @media (prefers-reduced-motion: no-preference) guard — your animations override the browser defaults.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Scroll Animations in 2026: @scroll-timeline, animation-timeline and View TransitionsCSS View Transitions API: Page Animations Without JavaScriptView Transitions API: Cross-Document Animations in 2026Page Transitions in Next.js App Router: View Transitions API