loke.dev
Header image for How to Achieve SPA-Like Page Transitions Without a Single Line of JavaScript

How to Achieve SPA-Like Page Transitions Without a Single Line of JavaScript

Stop sacrificing the simplicity of multi-page architectures for the smoothness of an SPA by leveraging the new native @view-transition rule.

· 5 min read

I spent three hours last Tuesday trying to debug a React transition group that just wouldn't play nice with a sticky header. By the time I fixed the z-index and handled the unmounting logic, I realized I’d written 40 lines of code just to make a div fade out gracefully.

For years, if you wanted "app-like" smoothness where elements glide from one page to another, you had to go the Single Page Application (SPA) route. You needed a router, a state manager, and a whole lot of JavaScript to prevent the browser's default behavior of clearing the screen and starting over.

But things have changed. We can finally have our cake (the simplicity of a Multi-Page Architecture) and eat it too (silky smooth transitions) using nothing but CSS.

The "White Flash" Problem

We’ve all seen it. You click a link on a standard website, the screen flashes white for a fraction of a second, and then the new content pops into existence. It feels clunky.

The View Transitions API fixes this by taking a snapshot of the old page and the new page, then animating between them. While there is a JavaScript API for this, the real magic is the new CSS-only opt-in for cross-document transitions.

The One-Liner That Changes Everything

To get started, you don't need a library. You don't even need a script tag. You just need to tell the browser that you want it to handle the transition automatically when navigating between pages of the same origin.

Drop this into your global CSS file:

@view-transition {
  navigation: auto;
}

That’s it. Seriously.

With this single rule, the browser will now perform a cross-fade transition whenever a user clicks a link to another page on your site. No more harsh "white flash." The outgoing page fades out while the incoming page fades in simultaneously.

Moving Beyond the Basic Cross-Fade

A cross-fade is nice, but the real "SPA feel" comes when specific elements—like a hero image or a search bar—actually move across the screen to their new positions.

To do this, we use the view-transition-name property. This tells the browser: "Hey, this element on Page A is actually the same thing as this element on Page B. Connect the dots."

Imagine you have a thumbnail on a blog list page and a large header image on the actual post page.

On your List Page:

.card-thumbnail {
  view-transition-name: hero-image;
}

On your Article Page:

.article-header-img {
  view-transition-name: hero-image;
}

The browser will now calculate the position and size of .card-thumbnail, see where .article-header-img is on the next page, and literally animate the pixels from one to the other. It’s like magic, but without the technical debt.

Customizing the Vibe

The default transition is a 0.25s ease, which is fine, but maybe you want something a bit more dramatic—or perhaps something snappier.

The browser uses a set of pseudo-elements to handle these animations. You can target them just like regular CSS classes. Here is how you can slow things down and add a custom timing function:

::view-transition-group(hero-image) {
  animation-duration: 0.5s;
  animation-timing-function: cubic-bezier(0.5, 0, 0, 1);
}

/* You can even target the old and new states specifically */
::view-transition-old(hero-image) {
  /* Style for the outgoing image */
}

::view-transition-new(hero-image) {
  /* Style for the incoming image */
}

A quick tip: The view-transition-name can be anything you want, but it must be unique on the page. If you have ten blog cards, you can't give them all the name hero-image. You’d need to apply the style dynamically (perhaps via inline styles) so each card has a unique ID like hero-1, hero-2, etc.

The "Gotchas" (Because Nothing is Ever Perfect)

Before you go deleting your node_modules folder, there are a few things to keep in mind.

1. Browser Support: As of right now, this is a Chromium-heavy feature (Chrome, Edge, Opera). Safari and Firefox are dragging their feet, though it is under active development. The beauty of this approach, however, is that it's a progressive enhancement. If a browser doesn't support @view-transition, the user just gets a normal, boring page load. Nothing breaks.
2. The Origin Constraint: This only works for same-origin navigations. You can't animate a transition from your site to Google.com (thankfully).
3. The "Active" State: If you have animations running on the page when the transition starts (like a spinning loader), they might look frozen during the snapshot phase unless you've tuned your CSS specifically to handle it.

Why This Matters

We spent a decade making web development incredibly complex just to get these types of transitions. We moved all our routing to the client side, which created huge bundles and SEO headaches, mostly because we wanted the UI to feel "premium."

By moving this logic into the browser's engine, we're returning to a world where a simple HTML site—built with Astro, 11ty, or even just plain PHP—can feel just as high-end as a massive React app.

Go try it out. Open your CSS, add that @view-transition block, and watch your site instantly feel like it was built in 2025.