Writing a Same-Document View Transition: the CSS You Actually Author
You have a panel that switches between a grid arrangement and a list arrangement, or a tab strip whose content is replaced on click. The change is one class toggle. Without help the swap is instantaneous and slightly disorienting — content jumps from one arrangement to another with no visual thread connecting them. A same-document view transition supplies that thread, and this page walks through exactly which CSS you write to control it. It assumes the model described in View Transitions for CSS Developers: the browser takes a picture before, a picture after, and animates between them.
The narrow question here is not what a view transition is. It is: given a working transition, what are the smallest, most useful CSS declarations, in what order should you reach for them, and where does each one break?
Why reach for this instead of a plain transition
A CSS transition needs a living element whose property values change. That covers hover, focus, and toggles — the territory of CSS Transition Fundamentals. It does not cover a change where the old content is removed from the DOM and different content takes its place, because there is no continuous element to interpolate on.
You can fake continuity with @starting-style for elements arriving and transition-behavior: allow-discrete for elements leaving, and for a single panel that is often the right call — it is pure CSS, it needs no JavaScript entry point, and it does not freeze the page. But it falls apart when the change is structural: when a grid becomes a list, twenty elements each need their own enter and exit choreography, and none of them can visually travel from where the old layout put them to where the new layout puts them. That is precisely the gap a view transition fills, and it is why the two techniques coexist rather than compete. If your change is one element appearing or disappearing, prefer @starting-style entry animations. If it is a re-arrangement, use a view transition.
The accessibility tradeoff is real and worth stating: for the duration of a view transition the page is a picture. Nothing is clickable, nothing is selectable, and focus is wherever you left it. That is an argument for short durations and for never chaining transitions back to back.
The two snapshots share one box
Everything about authoring these animations follows from one fact: ::view-transition-old() and ::view-transition-new() are absolutely positioned siblings inside the same image pair, stacked on top of each other with the new one on top. The default animations are chosen so that stacking is invisible — as one fades out the other fades in, and at every instant the composite reads as a single image.
The moment you replace those animations you own that problem. If you give the new snapshot a slide-in and leave the old one opaque, the old one sits underneath the whole time and you get a doubled, ghosted image. Custom animations almost always come in pairs.
The demo below runs those custom keyframes on ordinary elements, so you can judge the curve and the travel distance before wiring anything up.
Complete working implementation
A tabbed panel where selecting a tab replaces the panel body. The old body exits to the left, the new body enters from the right, and the surrounding chrome is left alone by giving the panel its own name so only it animates.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Same-document view transition</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
.tabs { display: flex; gap: .5rem; margin-bottom: 1rem; }
.tab {
padding: .5rem 1rem; border: 1px solid currentColor;
border-radius: 999px; background: none; cursor: pointer;
}
.tab[aria-selected="true"] { background: currentColor; color: Canvas; }
.panel {
padding: 1.5rem;
border: 1px solid currentColor;
border-radius: 12px;
/* Lifts the panel out of the root snapshot so only this box animates
and the tab strip above it stays perfectly still. */
view-transition-name: tab-panel;
}
/* Give the overlay an opaque backdrop. Without it, the moment both
snapshots are partly transparent you see straight through to the
live DOM underneath, which reads as a flash. */
::view-transition { background-color: Canvas; }
/* Retime the group so the panel's size change tracks the content swap. */
::view-transition-group(tab-panel) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes vt-slide-out-left {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-30%); }
}
@keyframes vt-slide-in-right {
from { opacity: 0; transform: translateX(30%); }
to { opacity: 1; transform: translateX(0); }
}
/* 'both' is load-bearing: without a fill mode the snapshot reverts to
its start value on the final frame and you get a one-frame flash. */
::view-transition-old(tab-panel) {
animation: vt-slide-out-left 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(tab-panel) {
animation: vt-slide-in-right 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
</style>
</head>
<body>
<div class="tabs" role="tablist">
<button class="tab" role="tab" aria-selected="true" data-body="Colour tokens live in one layer.">Design</button>
<button class="tab" role="tab" aria-selected="false" data-body="Every component ships its own styles.">Code</button>
<button class="tab" role="tab" aria-selected="false" data-body="Budgets are enforced in CI.">Perf</button>
</div>
<div class="panel" id="panel" role="tabpanel" tabindex="0">
<p>Colour tokens live in one layer.</p>
</div>
<script>
const panel = document.getElementById('panel');
function show(tab) {
document.querySelectorAll('.tab').forEach(t =>
t.setAttribute('aria-selected', String(t === tab)));
panel.innerHTML = '<p>' + tab.dataset.body + '</p>';
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
// Feature test: unsupported engines get the content change instantly.
if (!document.startViewTransition) { show(tab); return; }
document.startViewTransition(() => show(tab));
});
});
</script>
</body>
</html>
document.startViewTransition() is the only JavaScript entry point for a same-document transition — there is no CSS-only trigger, and no :hover or :checked state can start one. The callback should do nothing but mutate the DOM; every decision about how the change looks lives in the stylesheet above it.
The key technique: pairing the exits and entrances
The single rule worth internalising is that a custom animation on one snapshot obliges you to write the matching one on the other. The user agent's defaults are a matched pair — a fade out and a fade in of identical duration — and their symmetry is what makes them invisible.
Two failure modes follow from breaking the pair. If you animate only ::view-transition-new(), the old snapshot holds at full opacity beneath it and the incoming content appears to slide over a frozen copy of the outgoing content. If you give the two different durations, the transition runs for the length of the longer one, so a 150ms exit against a 400ms entrance leaves 250ms where the old snapshot has already gone and the new one is still arriving over an empty backdrop.
The fix for the first is simply to declare both. The fix for the second is either to match the durations or to overlap them intentionally with a delay — as in the card example where the entrance starts 60ms after the exit begins, producing a brief hand-off rather than a hard cut. That kind of offset timing is standard @keyframes craft; the patterns transfer directly from Keyframe Animation Patterns.
One more detail belongs in this section: animation-fill-mode. A snapshot with no fill mode reverts to its unanimated state on the frame after the animation ends, but the overlay is not removed until all animations in the group finish. If your old snapshot ends at opacity: 0 without both, it pops back to opacity: 1 for the remaining frames of a longer group animation. Writing both in the shorthand costs four characters and removes a whole class of one-frame flashes.
Variation: direction-aware transitions
A left-to-right slide is wrong when the user goes backwards. Because the pseudo-element selectors are ordinary selectors, you can scope them under an attribute on the root element and set that attribute before calling startViewTransition.
/* Forward is the default declared above. Backward mirrors it. */
html[data-nav="back"] ::view-transition-old(tab-panel) {
animation-name: vt-slide-out-right;
}
html[data-nav="back"] ::view-transition-new(tab-panel) {
animation-name: vt-slide-in-left;
}
@keyframes vt-slide-out-right {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(30%); }
}
@keyframes vt-slide-in-left {
from { opacity: 0; transform: translateX(-30%); }
to { opacity: 1; transform: translateX(0); }
}
Set document.documentElement.dataset.nav = 'back' immediately before starting the transition and clear it afterwards. Because the snapshot styles are resolved when the overlay is built, the attribute only needs to be correct at that instant. The same hook is how you scope a transition to a theme, a locale, or a writing mode — for right-to-left interfaces, swap the sign on the translateX values or express them in logical terms with a custom property.
Browser support note
Same-document view transitions are available in Chrome and Edge from version 111, Safari from 18, and Firefox from 144 — every current engine supports them. The if (!document.startViewTransition) guard in the implementation above therefore protects older releases still in circulation rather than one hold-out engine, and what those users get is the plain DOM change with no animation. That remains a genuinely acceptable outcome, which is why the guard is written as a baseline experience rather than as a fallback path you have to design twice.
There is no @supports query that reliably tests for the pseudo-element tree, so do the detection in script. The CSS itself needs no guarding: an engine that does not know ::view-transition-old() discards those rule blocks as invalid selectors and never applies them, which is exactly the behaviour you want.
FAQ
Can I start a same-document view transition without JavaScript?
No. Inside a single document the only entry point is document.startViewTransition(), which takes a callback that performs the DOM change. The at-rule form only covers navigations between documents.
Why does my custom animation snap back at the end?
The snapshot returns to the animation's start value once it finishes because no fill mode is set. Add both to the animation shorthand so the final keyframe values are retained until the overlay is removed.
What happens if the old and new animations have different durations? The transition lasts as long as the longest animation in the group. A short old animation leaves an empty gap where the outgoing snapshot has already vanished, so match the durations or overlap them deliberately with a delay.
Do I have to write animation-name to change the timing?
No. Setting animation-duration and animation-timing-function on the old and new pseudo-elements keeps the user-agent keyframes and only retimes them, which is the smallest useful override.
Related
- View Transitions for CSS Developers — the parent guide covering the snapshot model and the full pseudo-element tree.
- View transition names and shared elements — going beyond the root snapshot to morph one element into another.
- View transitions with reduced motion — what to do with these slide keyframes when the user asks for less movement.
- Transitioning display with allow-discrete — the pure-CSS alternative when only one element enters or leaves.
- Container query sidebar layouts — the kind of arrangement change that benefits most from a transition.
Related articles
More pages in the same section.