If you have played around with view transition a bunch, you may have noticed that 3D transitions between two pages (i.e., cross-document view transitions) don’t seem to work. That is, at least not without the browsers flattening things first.
Image elements are the best example to demonstrate this because, like the snapshots a browser takes of the before-after states in a view transition, images are replaced elements so, in theory, we should be able to use them as a sort of reduced test case for 3D animations. For example, flipping one image to reveal another on click looks like this:
It’s important to note that, for the animation to work properly, we need to set the
perspectiveproperty on the image’s parent container (in our case, it’s the.sceneelement). Otherwise, the 3D transformation is merely flat. It sort of anglesthe element’s appearance:In CSS, the parent’s
persepectiveis applied to all its children, excluding itself:.scene { perspective: 1200px; .card { /* gets perspective */ }}What’s important here is the HTML structure. Specifically how the
.scenecontainer sits on top of the child.cardelements, making the 3D effect come to life so the flip looks how it should:<div class="scene"><div class="card"><!-- Card Content Here --></div></div>Perhaps our keyframe animation to flip the
.cardsis something like this:@keyframes flipOut { from { transform: rotateY(0deg); } to { transform: rotateY(-90deg); }}Which we apply to the
.cardslike this:.card.flip-out { animation: flipOut 5.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;}.card.flip-in { animation: flipOut 5.2s cubic-bezier(0.4, 0, 0.2, 1) forwards reverse;}…where the animates runs
forwardswhen the.flip-outclass is appended to the.card(courtesy of JavaScript watching for a click) and runs inreversewhen the.flip-inclass is appended.That’s the setup for how a cross-document view transition ought to work, too, right? If an image supports a 3D animation, then a view transitions snapshot should do the same. Let’s poke at that.
Setting up the view transition
First things first, we have to opt into view transitions on both pages with the
@view-transitionat-rule by setting thenavigationdescriptor toauto:@view-transition { navigation: auto;}If we were to do nothing else, then one page fades into another when navigating between the two. It’s the most basic of all cross-document view transitions.
How do we customize things? We use the
::view-transition-old()and::view-transition-new()pseudo-classes, where the former is the “old” snapshot and the latter is the “new” one. Like the.cardelements we used in the last example, that’s where we set the keyframe animation:::view-transition-old(root) { /* animation goes here */}::view-transition-new(root) { /* animation goes here */}The
rootparameter tells the view transition to target the whole page and all the elements created (and not created) by the view transition’s default snapshot group.Here’s the problem
Let’s say we want to apply that same 3D flip to the entire webpage, where the snapshot of the “old” page flips into the “new” page. Again, a 3D animation asks us for two things:
- The
perspectiveproperty on the parent element so its children get that 3D effect - An animation on the page for when the view transition happens
But: What exactly do we set the perspective on, as in, what is the parent element here?
Since view transitions take snapshots of the entire webpage, we might assume (logically) it would be the
<html>element (or the:root), right? I mean, the DOM tree looks like this when a view transition is present:html ├─ ::view-transition │ ├─ ::view-transition-group(card) │ │ └─ ::view-transition-image-pair(card) │ │ ├─ ::view-transition-old(card) │ │ └─ ::view-transition-new(card) │ └─ ::view-transition-group(name) │ └─ ::view-transition-image-pair(name) │ ├─ ::view-transition-old(name) │ └─ ::view-transition-new(name) ├─ head └─ body └─ …So, the entire snapshot should be where we put the
perspective. Right? Turns out, no.In fact, does nothing at all! You’re left with this instead of the beautiful 3D flip we were able to use on the cards earlier:
GitHub Source and Live Demo Here’s the code I was working with:
/* Cross-document View Transition opt-in */@view-transition { navigation: auto;}/* 3D flip: Old page flips away, new page flips in */@keyframes flip-out { 0% { transform: rotateY(0deg); opacity: 1; } 100% { transform: rotateY(-90deg); opacity: 0; }}@keyframes flip-in { 0% { transform: rotateY(90deg); opacity: 0; } 100% { transform: rotateY(0deg); opacity: 1; }}::view-transition-old(root) { animation: flip-out 0.3s cubic-bezier(0.4, 0, 1, 1) forwards; transform-origin: center center;}::view-transition-new(root) { animation: flip-in 0.3s cubic-bezier(0, 0, 0.6, 1) 0.3s backwards; transform-origin: center center;}Note:I didn’t reverse the animation here since we flip to
-90degand then from90deg. Not exactly the same!And it doesn’t work, no matter if
perspectiveis onhtmlor:root:/* 👎 */html { perspective: 1100px;}/* 👎 */:root { perspective: 1100px;}I did some digging and discovered that
perspective(and 3D transformations in general) is one of several CSS properties that would produce an unusual effect. (Leave it to Bramus to have the answer!)So… What do we do? Some ideas came to mind, but sadly failed:
- I tried setting the
perspectiveproperty on thebody. - I tried setting
perspectiveinside::view-transition-group(root). - I tried setting
perspectiveinside the::view-transitionpseudo.
There’s actually a super simple workaround to this, and I can’t believe it took me this long to figure it out — don’t use
perspectiveat all!The solution
Short story: we have to use the
perspective()function instead of theperspectiveproperty. And not inside any of the::view-transition-*pseudos as you might expect, but inside the@keyframesanimation:@keyframes flip-out { 0% { transform: perspective(1100px) rotateY(0deg); opacity: 1; } 100% { transform: perspective(1100px) rotateY(-90deg); opacity: 0; }}@keyframes flip-in { 0% { transform: perspective(1100px) rotateY(90deg); opacity: 0; } 100% { transform: perspective(1100px) rotateY(0deg); opacity: 1; }}This simple, but big change moves the scene from a flat mehto a beautiful ah yeah:
GitHub Source and Live Demo Here’s why, apparently. The view transition pseudo-element tree is rendered outsidethe normal HTML flow. More specifically, the entire view transition tree is rendered above the DOM in its own layer. However, particularly for
::view-transition, I’m not too sure why this is the case, but my best guess would be that each view transition group automatically has its position and transform values overridden by the browser; hence, interfering with theperspective.The difference between
perspectiveandperspective()? Theperspectiveproperty is applied to the parent element, whileperspective()is atransformproperty function applied directly to the element itself. And since the view transition pseudo tree does not have a true parent, we’ve gotta useperspective()since it doesn’t require a parent. Phew.To recap…
Setting
perspectiveon thehtml,:root, or any of the view transition pseudo-class won’t work. And if you have been struggling to find the solution, like I was, I think this little, but bigperspective()change will solve that issue if you ever come across it. Take it from me, I battled with this for weeks till I came back today to rant about it and discovered a solution to it. A perk of writing!- Setting up the view transition
- Here’s the problem
- The solution
- To recap…
顶: 75踩: 38219Comments
Leave a Reply Cancel reply
- The
Why Isn't My 3D View Transition Working?
人参与 | 时间:2026-06-17 21:34:17
评论专区
相关文章
- How to Scale Laravel Applications for High
- Nikhil Adithyan
- How to Build a Case Converter Tool Using HTML, CSS, and JavaScript
- Microservices
- How to Build Optimal AI Agents That Actually Work – A Handbook for Devs
- How to Build a Browser
- Manish Shivanandhan
- How to Build a Browser
- Traffic Analytics Setup and Configuration
- Machine Learning




You can also just set perspective on the parent as usual. The only thing to remember is that, when animating view transition pseudos, the direct parent of both the old and new images is the image-pair pseudo element:
For more context see Fun with View Transitions
ReplyHelpful!
Thanks for this.
Great writeup! Worth noting that if you’re using a parent
Replyperspectiveproperty withtransform-style: preserve-3dto create a shared 3D space, Safari has an additional issue. It flattens the 3D context entirely when taking snapshots, so the workarounds above won’t help. This affects view transitions and tab thumbnails alike. There are two open WebKit bugs tracking it (#283568 and #302166) with little activity since filing, so any extra visibility helps.Wow.
Thank you for this! This is something worth noting for the community. I hope this gets fixed real soon!