Skip to main content
Martin.

Natural View Transitions

The View Transitions API is incredibly cool. It's also incredibly easy to make your site feel like a PowerPoint presentation.

Every demo I've seen does the same thing: a big slide or scale, content flying in from the edges, maybe some morphing hero images. It looks great in a two-second screen recording. It feels terrible when you're actually trying to use a website.

I spent a while getting the transitions on this site to a place I'm happy with, and the biggest lesson was this: the best transitions are the ones nobody notices. Not because they're invisible, but because they feel like a natural part of how the page breathes.

Here's how I think about it.

The Philosophy: Editorial Crossfades

I landed on a mental model I call "editorial crossfades". Think of it like turning a page in a well-designed magazine — content recedes quietly, new content settles into place. There's a continuous dissolve, not a blink-then-appear.

Three rules I follow:

  1. Exits are fast and opacity-driven. Old content should get out of the way quickly, with minimal spatial movement. You don't need to see it leave — you just need it to not be there anymore.
  2. Entrances are smooth with minimal drift. New content settles gently into its final position. Just enough movement to create a sense of arrival, not enough to be distracting.
  3. Exit and entry overlap. The old content is still fading out while the new content starts fading in, creating a continuous dissolve rather than a gap.

Killing the Defaults

The first thing I do is disable the default root transition. The built-in cross-fade on the entire page is too blunt — it creates a uniform flash that makes every navigation feel the same.

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

With the root transition gone, I can control each piece of the page independently.

Three Layers, Three Personalities

I break every page down into three transition layers, each with its own character:

1. Page Content — A Near-Pure Crossfade

The main content wrapper gets the subtlest treatment. Only 4px of vertical drift — just enough to keep it feeling alive. The exit is fast (150ms), the entrance is slower (300ms), and they overlap by about 80ms.

@keyframes page-fade-out {
  from { opacity: 1; transform: translateY(0); }
  to   { opacity: 0; transform: translateY(-4px); }
}

@keyframes page-fade-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}

::view-transition-old(page-content) {
  animation: 150ms cubic-bezier(0.7, 0, 0.84, 0) both page-fade-out;
}

::view-transition-new(page-content) {
  animation: 300ms cubic-bezier(0.25, 1, 0.5, 1) 70ms both page-fade-in;
}

The 70ms delay on the entrance is doing a lot of work here. It means the old content is already half-faded before the new content starts appearing. Without it, both layers are fully visible at the same time and it looks muddy.

Also notice the easing: the exit uses cubic-bezier(0.7, 0, 0.84, 0) — a sharp ease-in that clears quickly. The entrance uses cubic-bezier(0.25, 1, 0.5, 1) — a gentle ease-out that decelerates into the final position. Fast out, slow in.

Exit — ease-in

time → progress →

cubic-bezier(0.7, 0, 0.84, 0)

Entrance — ease-out

time → progress →

cubic-bezier(0.25, 1, 0.5, 1)

2. Page Title — Anchored and Dignified

This is the one I'm most opinionated about. Serif type — especially at large sizes — should feel anchored. Like a printed headline that was always there. Moving it spatially feels wrong. Scaling it feels worse.

So the title gets opacity only. No translateY, no scale, nothing.

@keyframes title-fade-out {
  from { opacity: 1; }
  to   { opacity: 0; }
}

@keyframes title-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

::view-transition-old(page-title) {
  animation: 120ms cubic-bezier(0.7, 0, 0.84, 0) both title-fade-out;
}

::view-transition-new(page-title) {
  animation: 280ms cubic-bezier(0.25, 1, 0.5, 1) 60ms both title-fade-in;
}

It's the fastest transition of the three. The title dissolves and reappears almost instantly. That speed makes it feel stable — it's not going anywhere, the content underneath is just changing.

3. Cards — Staggered Settle

Cards get the most spatial movement: 8px of vertical rise. But the key is in how the stagger works.

Exits happen together — all cards fade out simultaneously, as one unit. No stagger. The old content leaves as a cohort, fast and uniform:

::view-transition-old(card-1),
::view-transition-old(card-2),
::view-transition-old(card-3),
::view-transition-old(card-4),
::view-transition-old(card-5) {
  animation: 120ms cubic-bezier(0.7, 0, 0.84, 0) both card-fade-out;
}

Entrances stagger — 40ms between each card, starting at 100ms. This creates a gentle cascade as the new content settles in:

::view-transition-new(card-1) {
  animation: 320ms cubic-bezier(0.25, 1, 0.5, 1) 100ms both card-settle-in;
}

::view-transition-new(card-2) {
  animation: 320ms cubic-bezier(0.25, 1, 0.5, 1) 140ms both card-settle-in;
}

::view-transition-new(card-3) {
  animation: 320ms cubic-bezier(0.25, 1, 0.5, 1) 180ms both card-settle-in;
}

/* ...up to card-5 */

The asymmetry matters. Staggering exits looks chaotic — things disappearing at different times feels broken. Staggering entrances looks intentional — things appearing in sequence feels like they're being placed.

Naming Things in the JSX

The CSS targets view-transition-name values. In the markup, I apply them as inline styles:

{/* The main content wrapper, in __root.tsx */}
<main style={{ viewTransitionName: 'page-content' }}>
  <Outlet />
</main>

{/* Page titles */}
<h1 style={{ viewTransitionName: 'page-title' }}>
  Building things for the web
</h1>

{/* Cards get numbered names for the stagger */}
{posts.map((post, index) => (
  <div
    key={post.slug}
    style={{ viewTransitionName: `card-${index + 1}` }}
  >
    {/* ... */}
  </div>
))}

I cap the card names at 5. If you have more, they share card-5's timing — which is fine, because beyond 5 items the stagger effect stops being perceptible anyway. The total spread is 200ms (5 cards × 40ms gap), which is tight enough to feel like a single cohesive motion.

The Numbers That Matter

After a lot of tweaking, here are the values I keep coming back to:

Element Exit Duration Enter Duration Enter Delay Drift
Content 150ms 300ms 70ms 4px
Title 120ms 280ms 60ms 0px
Cards 120ms 320ms 100ms + stagger 8px

A pattern emerges: exits are roughly half the duration of entrances. Delays are roughly half the exit duration. This ratio creates that dissolve feel I keep talking about.

Reduced Motion

If the user prefers reduced motion, every transition is set to animation: none. No compromises, no "softer" versions. The content just appears instantly, which is fine — that's what the user asked for.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(page-content),
  ::view-transition-new(page-content),
  ::view-transition-old(page-title),
  ::view-transition-new(page-title),
  ::view-transition-old(card-1),
  ::view-transition-old(card-2),
  /* ...all card names... */
  ::view-transition-new(card-5) {
    animation: none;
  }
}

What I Tried That Didn't Work

Some things that sounded good in theory:

  • Morphing elements between pages. Titles that slide from their position on one page to their position on the next. Looks incredible in demos, feels disorienting on a text-heavy site. Your eye has to track a moving target.
  • Scale on titles. Even a subtle scale(0.98) on a serif headline makes it look wobbly. Typography at rest has gravity; animation undermines it.
  • Staggering exits. Cards disappearing one by one looks like the page is falling apart. Exits should be decisive.
  • Long durations. Anything over 350ms starts to feel like the site is loading slowly rather than transitioning gracefully. Speed implies responsiveness.
  • 3D transforms. perspective and rotateX on page content. Just... no. We're not building a carousel.

The Short Version

If you're adding view transitions to a content site, my advice is:

  1. Disable the default root transition
  2. Move less than you think you should (4–8px is plenty)
  3. Make exits fast and uniform
  4. Make entrances slower with a slight delay
  5. Stagger entrances, never exits
  6. Respect prefers-reduced-motion completely

The goal isn't to impress someone watching over your shoulder. It's to make navigation feel like breathing — you don't notice it, but you'd feel wrong without it.