Skip to main content
Average reading time: 7 minutes, 11 seconds

User Rating: 5 / 5

Total Votes: 1
Please Rate

How it works

When two pages on the same site both include @view-transition { navigation: auto; } in their CSS, the browser animates every link click between them — no JavaScript, no framework, no change to how the site is built.

On each navigation the browser runs four steps by itself:

  1. Snapshot the old page. Just before leaving, it captures the current page, plus a separate snapshot of each element that has a view-transition-name.
  2. Load the new page. The new page loads and renders off-screen; the user keeps seeing the frozen old page.
  3. Capture the new page. It records the new page and every named element on it.
  4. Animate. It lays a tree of ::view-transition-* pseudo-elements over the page and animates from old to new. Default: a 250 ms cross-fade, plus a move-and-resize for elements that share a name on both pages.

Everything you customise is plain CSS: which elements get names, and which @keyframes the pseudo-elements run.

The demos below are pairs of static HTML pages with no scripts. Each screenshot was captured in Chromium halfway through the transition so you can see the frame between the two pages.

Browser support

Cross-document view transitions work in Chrome and Edge 126+ and Safari 18.2+; Firefox does not support them yet.

Browser Cross-document (@view-transition)
Chrome / Edge 126+
Safari 18.2+
Firefox Not supported yet

Sources: Chrome cross-document guide; check caniuse before shipping, since support changes quickly.

Step 1: Opt in — the default cross-fade

Add one rule to a stylesheet that every page loads, and every same-site link click cross-fades from the old page to the new one.

@view-transition {
  navigation: auto;
}

That is the whole step. Both pages need the rule; put it in your main theme stylesheet so it is everywhere.

Default cross-fade: old page, middle frame, new page
In the middle frame both pages are visible at half opacity: the old heading “Welcome home” and the new “About us” overlap. The header looks still only because it is identical on both pages; it is actually fading too. Step 2 makes it truly still.

navigation: auto animates link clicks, form submissions and the back/forward buttons. It does not animate a URL typed in the address bar, a bookmark, or a reload. Use navigation: none on a page (or inside a media query) to switch transitions off there.

Step 2: Keep the header still and morph shared elements

Give an element the same view-transition-name on both pages and the browser moves and resizes it from its old spot to its new spot, instead of fading it.

@view-transition { navigation: auto; }

/* identical on every page → stays perfectly still */
header     { view-transition-name: site-header; }

/* the post image on the index AND the article page */
.post-hero { view-transition-name: post-hero; }
Shared element morph from card to banner
The header has its own name, so it is not part of the page-wide fade; only the changed words inside it blend. The orange image travels from the 260 px card on the index to the 640 px banner on the article.

Rules for names:

  • One element per name per page. If two visible elements share a name, the transition is skipped and the console shows an error. Step 4 covers listing pages with many cards.
  • Names must match exactly between the old and the new page. If the new page has no element with that name, the old one simply fades out.
  • view-transition-name: none (the default) leaves the element inside the page-wide fade.

Fixing the aspect-ratio problem

The card image and the banner have different shapes, and by default the snapshots keep their own width and height: auto. The result is an image that spills out of its box during the move:

Image spilling out of its box during the morph

Tell the snapshots to fill the moving box and crop like a cover image:

::view-transition-old(post-hero),
::view-transition-new(post-hero) {
  height: 100%;
  object-fit: cover;
  overflow: clip;   /* without this the cropped part still paints */
}

Step 3: Custom animations with the pseudo-elements

During a navigation the browser draws a tree of pseudo-elements over the page; each one is an ordinary CSS target, so you replace the default fade with your own @keyframes.

::view-transition pseudo-element tree
::view-transition pseudo-element tree · root + content

The highlighted branch belongs to the content area restyled below (the header's own site-header branch is left out of the picture for space). What each part does:

Pseudo-element What it is What you usually change
::view-transition Overlay that holds everything Rarely
::view-transition-group(name) Box that moves and resizes from the old spot to the new animation-duration, easing
::view-transition-image-pair(name) Container that isolates the blend Rarely
::view-transition-old(name) Screenshot of the old page's element The exit animation
::view-transition-new(name) Live rendering of the new page's element The entry animation

* matches every name: ::view-transition-group(*) targets all groups.

Slide the content, keep the header

Name the main content area, then give its old and new snapshots slide keyframes. The header is named too, so it stays put:

@view-transition { navigation: auto; }

header { view-transition-name: site-header; }
main   { view-transition-name: content; }

@keyframes slide-out { to   { transform: translateX(-80px); opacity: 0; } }
@keyframes slide-in  { from { transform: translateX(80px);  opacity: 0; } }

::view-transition-old(content) { animation: .35s ease-in  both slide-out; }
::view-transition-new(content) { animation: .35s ease-out both slide-in;  }
Content sliding while the header stays still

Slow everything down while testing:

::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation-duration: 2s; }

Step 4: Many cards on a listing page

A listing page shows many posts, but each name may appear only once, so give each card a name from its post id and style them all with one shared view-transition-class.

The demo switches a blog between a grid page and a list page. Each card carries a unique name in its markup (written by the template, see the next section):

<a class="card" href="/blog/meteora" style="view-transition-name: post-2">…</a>

The CSS then never mentions individual names. view-transition-class adds a shared hook, and *.card in the pseudo-element selector matches every group with that class:

@view-transition { navigation: auto; }

.card { view-transition-class: card; }

/* every card: slightly springy move */
::view-transition-group(*.card) {
  animation-duration: .6s;
  animation-timing-function: cubic-bezier(.4, 1.4, .5, 1);
}

/* grid card and list row have very different shapes:
   show only the new layout while it moves */
::view-transition-old(*.card) { display: none; }
::view-transition-new(*.card) { animation: none; }
Cards moving from grid to list layout
In the middle frame each card is still offset towards its old grid position and already drawn in its new list shape. Hiding the old snapshot avoids a blurry blend of two very different layouts; use the object-fit fix from Step 2 instead when the shapes are similar.

view-transition-class is supported in Chrome and Edge 125+ and Safari 18.2+, so it works everywhere cross-document transitions work.

Adding it to Joomla

The transition CSS goes in the theme's custom stylesheet; per-post names need a small template change, because only the template knows each post's id.

Where the CSS goes

Platform Put the CSS in
Joomla 5/6, Cassiopeia media/templates/site/cassiopeia/css/user.css (create it if missing; Cassiopeia loads it automatically)
Joomla, other templates The template's custom CSS field or file

The header and content names from Steps 2 and 3 need no template change: target your theme's existing selectors (for Cassiopeia, header.header and main).

Per-post names in Joomla

Override the two image layouts in your template: copy layouts/joomla/content/intro_image.php and full_image.php to templates/cassiopeia/html/layouts/joomla/content/. In both copies, add the name to the <figure> that wraps the image:

<!-- keep the <figure>'s existing attributes; add only the style -->
<figure class="… item-image"
        style="view-transition-name: post-<?php echo (int) $displayData->id; ?>">

The blog card (intro image) and the article (full image) now carry the same name, so the image morphs from list to article.

Limits, reduced motion and debugging

Keep pages fast and on one origin, switch motion off for users who ask for it, and slow animations down in DevTools when something looks wrong.

Limits

  • Same origin only. Both pages must share scheme, host and port: https://example.gr to https://shop.example.gr will not animate.
  • Both pages opt in. If either page lacks @view-transition, the navigation is instant.
  • About 4 seconds. If the new page takes longer than that to render, the browser skips the transition.
  • Unique names. A duplicate name on either page cancels the whole transition.
  • Keep it short. 250–500 ms; the user cannot interact with the new page until the animation ends.

Reduced motion

Some visitors get dizzy from large movement. Switch transitions off for them entirely:

@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

Or keep the gentle cross-fade and remove only your slides and morphs:

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

Debugging in Chrome DevTools

  1. Open More tools → Animations and set the speed to 10% or 25%, then click a link to watch the transition slowly.
  2. Press pause in the Animations panel mid-transition. The ::view-transition tree appears in the Elements panel under <html>, and you can edit each pseudo-element's styles live.
  3. Nothing animates? Check the Console for a duplicate view-transition-name error, and confirm the new page also loads the stylesheet with @view-transition.

Cheat sheet

I want to… CSS
Turn on page transitions @view-transition { navigation: auto; } on every page
Keep the header or menu still header { view-transition-name: site-header; }
Morph an element between pages Same view-transition-name on both pages
Stop images stretching during a morph height: 100%; object-fit: cover; overflow: clip on ::view-transition-old/new(name)
Change exit / entry animation ::view-transition-old(name) / ::view-transition-new(name) + @keyframes
Change speed or easing ::view-transition-group(name) { animation-duration: … }
Style many named elements at once view-transition-class: x + ::view-transition-group(*.x)
Turn off on one page @view-transition { navigation: none; } on that page
Respect motion settings @media (prefers-reduced-motion: reduce) → navigation: none
Further reading on links below

Share this article