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:
- 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.
- 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.
- 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
cubic-bezier(0.7, 0, 0.84, 0)
Entrance — ease-out
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.
perspectiveandrotateXon 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:
- Disable the default root transition
- Move less than you think you should (4–8px is plenty)
- Make exits fast and uniform
- Make entrances slower with a slight delay
- Stagger entrances, never exits
- Respect
prefers-reduced-motioncompletely
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.