A cross-document view transition needs two things. Both pages opt in with the CSS rule @view-transition { navigation: auto; }, and the element you want to morph carries the same unique view-transition-name on each page. The browser snapshots the old page, loads the new one, and animates position and size between the two matching elements.
On our work page, click a project and its name does not disappear and reappear. It travels from the list row to the top of the next page and grows into the headline. The same thing happens from the homepage: click CoverageCobra or RankAZona in the product reel and both the title and the screenshot move into place on the case page.
These are separate HTML documents. There is no client-side router and no animation library. The site is plain files, and the morph is the browser's own View Transitions API working across a full page navigation.
We wanted to know how far the platform feature goes without a framework. This is everything we wrote to get it.
How do you turn on view transitions between two pages?
You turn on cross-document view transitions with one CSS at-rule, @view-transition { navigation: auto; }, present in the stylesheet of both the page you leave and the page you arrive at. It applies only to same-origin navigations. With that rule alone, every link click becomes a short crossfade.
Here is the rule exactly as it sits in our stylesheet:
@view-transition {
navigation: auto;
}
Because every page on this site loads the same site.css, every page opts in. That matters: if only one side has the rule, nothing animates. The crossfade you get by default is subtle, and it is a fair place to stop. The interesting part is naming elements.
How do you morph one element into another across pages?
To morph an element across pages, give the element on the old page and its counterpart on the new page the same view-transition-name. The browser then treats them as one object, captures each, and animates the position and size from the old box to the new box while crossfading the contents.
Our build script writes the name into both templates, using the project slug so every name is unique. This is the row on the work index and the headline on the case page:
// work index row
`<span class="n" style="view-transition-name:name-${p.slug}">${e(p.name)}</span>`
// case page
`<h1 style="view-transition-name:name-${p.slug}">${e(p.name)}</h1>`
The same pattern appears three more times in the build. Screenshots carry shot-${p.slug}, blog titles carry post-${p.slug} in the list and on the post, and the logo in the header has a fixed name, brand, so it holds still while the rest of the page changes.
One thing to understand before you rely on this for text. The browser animates snapshots, which behave like images. Our row name is small and the headline is very large, so in flight the text is being scaled, not re-typeset. With a short duration it reads as a morph. Stretch it out to a full second and you would see the trick.
Why does every name need to be unique?
Every view-transition-name must be unique among the elements rendered on a page at the moment of capture. If two visible elements share a name, the browser cannot tell which one to pair with the new page, and it skips the whole transition. Generating names from a slug or an ID avoids this.
This is an easy mistake to lose time on, because the failure is silent: the page just navigates with no animation. It is why we never write a name like title in the stylesheet for a class that appears more than once. A list of thirty rows needs thirty names, so the name goes on the element as an inline style at build time.
It also shapes the templates. A blog post page shows a "keep reading" list at the bottom, and each row there carries a post- name. The build leaves the current post out of that list, so the headline's name never appears twice on its own page.
How do you use view transitions inside a single page?
Inside one page, you call document.startViewTransition() and make your DOM change in the callback. The browser snapshots the page before the callback, runs it, then animates from the old state to the new one. Elements with a view-transition-name move to their new positions instead of fading.
Our work page has filter buttons. Each row in the list has its own name, row-${p.slug}, and the click handler wraps the filter in a transition when the API exists:
b.addEventListener('click', () => {
if (document.startViewTransition) document.startViewTransition(() => apply(b.dataset.kind));
else apply(b.dataset.kind);
});
apply only sets hidden on the rows that do not match. We wrote no animation code for the rows. Rows that survive the filter slide up to close the gaps, rows that are removed fade out, and rows that return fade in, all because each one has a name the browser can track.
| Cross-document | Same-document | |
|---|---|---|
| Trigger | A normal link click | A call to document.startViewTransition() |
| What you write | The @view-transition at-rule on both pages | A callback that changes the DOM |
| Where we use it | Work list to case page, blog list to post | The filter buttons on the work page |
| Without support | An ordinary page load | The change applies instantly |
What happens in browsers that do not support it?
In a browser without cross-document view transitions, the at-rule is ignored and a link is an ordinary page load. Nothing breaks, because the feature only adds an animation on top of navigation that already works. For the same-document version, you check that document.startViewTransition exists before calling it.
Cross-document transitions are available in Chromium-based browsers and in recent Safari. Support elsewhere has been arriving in stages, so check the current state before you promise the effect to a client. We treat it as decoration that some visitors get.
Some visitors should not get it. People who ask their device for reduced motion get no animation at all, and everyone else gets a slightly quicker, springier curve than the default:
::view-transition-group(*) {
animation-duration: 0.38s;
animation-timing-function: cubic-bezier(0.2, 0.75, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) {
animation: none !important;
}
}
What does it take to add this to an existing site?
Adding cross-document transitions to an existing multi-page site is mostly a templating job. You add the at-rule, pick the elements that represent the same thing on two pages, and write a unique matching name on each. No JavaScript is required for the navigation case.
The order we would do it in:
- Add
@view-transition { navigation: auto; }to the shared stylesheet and confirm the default crossfade works between two pages. - Pick one pair of elements that are the same thing on both pages, such as a list title and the detail page headline.
- Write a unique
view-transition-nameon both, generated from a slug or ID. - Check every template for duplicate names, including related lists and footers.
- Turn animations off under
prefers-reduced-motion: reduce. - Keep the duration short. Under half a second hides the snapshot scaling.
The transition can only start once the next page is ready to draw, so a slow page gets a late animation. We pair this with speculation rules that prerender the next page, which is what makes the morph start the moment you click. The sideways product reel those homepage links sit in is covered in our post on scroll-driven animations.
Frequently asked questions
Do view transitions work between two different websites?
No. Cross-document view transitions only run for same-origin navigations, and both pages have to opt in with the at-rule. A link to another domain, or to a page on your own site that does not load the rule, navigates the ordinary way with no animation.
Can you change the animation instead of using the default morph?
Yes. Each named element gets pseudo-elements you can target in CSS, such as ::view-transition-old(name) and ::view-transition-new(name), and you can attach your own keyframes to them. We only changed the duration and easing on the group, because the default position and size morph was already what we wanted.
Do you need a JavaScript framework to use view transitions?
No. The cross-document version needs only CSS and ordinary links, which is why it suits static sites. Single-page apps use the same-document call instead, usually wrapped around the router's update. Either way the browser does the animating, so there is no animation library to ship.
Does a view transition slow down page navigation?
The animation itself is short and runs on snapshots, so it adds little. What it cannot do is hide a slow page: the browser waits until the new page can render before animating. Make the destination fast first, then add the transition.
If you want a site that feels this considered without a heavy framework underneath, tell us what you are building.