Styling the View Transitions API with CSS: Beyond the Default
During a view transition you're animating still screenshots, not live DOM nodes, a detail most tutorials skip. It explains almost every 'why doesn't this work' question you'll hit later.
Paul Radford — Full-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

The View Transitions API ships with a default crossfade, but real control comes from targeting the generated pseudo-elements (::view-transition-group, ::view-transition-old, ::view-transition-new) with custom animation, transform, and mix-blend-mode values, and from scoping transitions per-element with view-transition-name. The default is a starting point, not the ceiling.
What actually happens under the hood
When you call document.startViewTransition(), the browser takes a screenshot of the old DOM state, lets your callback mutate the DOM, then takes a screenshot of the new state. Both snapshots get inserted into a pseudo-element tree that lives above your document, briefly, in a top-layer overlay. That tree looks roughly like this:
1::view-transition2└─ ::view-transition-group(root)3 └─ ::view-transition-image-pair(root)4 ├─ ::view-transition-old(root)5 └─ ::view-transition-new(root)Every element you tag with view-transition-name gets its own group, image-pair, old, and new pseudo-elements, named after whatever you assigned. This is the part people skip past in tutorials but it's the whole game: you are not animating your actual DOM nodes during the transition, you are animating still images of them. That distinction explains almost every "why doesn't this work" question you'll hit later.
The default animation the browser applies is a cross-fade combined with a size/position interpolation on the root group. It's implemented as actual CSS animations under the hood, which means you can override them with normal specificity rules, no JavaScript required for the visual part.
How do you override the default crossfade?
Target the pseudo-elements directly. They accept a real but limited set of CSS properties, mostly ones that affect compositing and geometry: animation, mix-blend-mode, opacity, transform, clip-path, filter, height, width, and a few others defined in the spec.
1/* Replace the default fade with a slide-and-fade */2: :view-transition-old(root) {3 animation: 200ms ease-out both slide-out;4}56: :view-transition-new(root) {7 animation: 250ms ease-in both slide-in;8}910@keyframes slide-out {11 to {12 transform: translateY(-24px);13 opacity: 0;14 }15}1617@keyframes slide-in {18 from {19 transform: translateY(24px);20 opacity: 0;21 }22}Note the both fill mode. Without it the pseudo-elements snap back to their computed style the instant the animation ends, which produces a visible flicker on the last frame. This is the single most common bug I see in first attempts at custom transitions.
Scoping transitions with view-transition-name
Tagging specific elements lets you run independent, named transitions instead of one blanket root crossfade. This matters for things like card-to-detail navigations where you want the clicked card to visually morph into the header of the next page.
1/* Tag the element that should get its own transition */2.product-card.is-active {3 view-transition-name: product-hero;4}56/* Give the tagged transition its own animation, separate from root */7: :view-transition-group(product-hero) {8 animation-duration: 400ms;9}1011: :view-transition-old(product-hero),12: :view-transition-new(product-hero) {13 mix-blend-mode: normal;14 height: 100%;15}Two rules that will save you debugging time. First, view-transition-name values must be unique in the document at the moment the transition snapshot is taken. If two elements share a name, Chrome throws and skips the transition entirely rather than silently doubling up. Second, only apply the name conditionally (as in .is-active above) and remove it afterward, otherwise every element with that name persists a transition group across unrelated navigations and you get stale animations firing on pages that shouldn't have them.
Why does my transition look wrong on differently-sized elements?
The default group animation interpolates width, height, transform, and border-radius between the old and new box geometry. If the old and new elements have very different aspect ratios, the built-in interpolation can look like an ugly squash. The usual fix is to opt the pair out of the automatic size morph and handle opacity or clip-path yourself:
1: :view-transition-image-pair(product-hero) {2 isolation: isolate;3}45: :view-transition-old(product-hero) {6 animation: none;7 mix-blend-mode: normal;8}910: :view-transition-new(product-hero) {11 animation: 300ms ease-out both clip-reveal;12}1314@keyframes clip-reveal {15 from {16 clip-path: inset(0 0 100% 0);17 }18 to {19 clip-path: inset(0 0 0 0);20 }21}Setting animation: none on the old snapshot stops the browser's default resize/cross-fade behavior for that specific pseudo-element while leaving the new one free to run your custom keyframes. This is the pattern I reach for whenever a card-to-page transition looks warped by default.
Handling reduced motion properly
Don't just wrap everything in a media query and call it done. The View Transitions spec does not automatically respect prefers-reduced-motion, so you own that decision:
1@media (prefers-reduced-motion: reduce) {2 : :view-transition-group(*),3 : :view-transition-old(*),4 : :view-transition-new(*) {5 animation: none !important;6 }7}Using the universal selector inside the pseudo-element function works in current Chromium and Firefox implementations and matches every named transition group at once, which is cleaner than duplicating the override for root and every named element individually.
Cross-document transitions change the rules slightly
Same-document transitions (the document.startViewTransition() API) have had solid support in Chrome and Edge since Chrome 111 (March 2023). Cross-document transitions, triggered by full navigations and controlled with the @view-transition { navigation: auto; } at-rule, arrived later and behave differently: you don't get a JavaScript callback to control the DOM swap, so all your styling has to live in CSS on both pages, and the outgoing page's styles apply to the old snapshot while the incoming page's styles apply to the new one. If you're building a multi-page site rather than an SPA, check current support on caniuse before committing, because cross-document support lagged same-document support by roughly a year and Safari's rollout has been slower and more limited than Chromium's.
Firefox shipped the same-document API behind a flag for a while and only moved toward stable support more recently, so if your audience skews Firefox-heavy, treat every visual enhancement here as progressive: the MDN reference documents which pieces are supported where, and it's worth rechecking before you ship because the matrix has moved a few times since 2023.
When not to reach for custom transition styling
If your transition is a simple fade on a low-traffic settings page, the default is fine and hand-rolling keyframes is wasted effort. Custom pseudo-element styling earns its complexity when the transition needs to communicate a specific spatial relationship, a card expanding into a page, a tab indicator sliding, a list reordering. If you find yourself fighting the size-interpolation behavior on more than one or two elements, it's often faster to disable the group's default animation entirely and drive the whole thing with a Web Animations API sequence, keeping CSS for the easy 80% and JavaScript for the one tricky transition that needs frame-level control.
Testing the result without guessing
Open DevTools and throttle CPU to 4x or 6x slowdown before judging any transition. Everything looks acceptable at full speed on a development machine; the squash, the flicker on animation end, the layout jump on the size morph, all show up once you simulate a mid-range device. Chrome's Rendering panel also lets you pause on the exact frame where the pseudo-element tree is active, which is the fastest way to inspect the generated ::view-transition-group box model in the Elements panel, since it doesn't otherwise show up in a normal DOM inspection. Check the W3C View Transitions spec directly when a property doesn't behave as MDN describes: the spec is still evolving and MDN occasionally lags a shipped Chromium behavior by a few weeks.
Related Articles

Animating to height auto using CSS interpolate-size
height: auto was never a number the browser could interpolate toward, which is exactly why it never animated at all. These two features finally give the engine something concrete to target.

CSS animation-composition: Controlling Layer Merge Behavior
A hover-triggered scale pulse used to kill a continuous background rotation animation outright the instant it started. This property lets both effects combine instead of one overriding the other.

How to use the advanced CSS attr() function for types and units
attr(data-color color, #333) feeding straight into background-color sounds like it already ships, but as of early 2026 it's spec-only. Here's what attr() actually does today, and what's coming.