View Transitions
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeDay 15! We're already halfway through our adventure. And it only gets cooler from here.
To celebrate, today we'll work on something sparkly & new: the View Transitions API. This nifty new feature is supported in most browsers and allows us to have smooth transitions whenever any HTML changes on our page.
Tip
Actually, as of Dec 2023, view transitions are supported only in Chrome with support in Firefox and Safari reportedly planned.
And if your user has a browser that doesn't support it, that's ok! The transition is just skipped, but everything keeps working. No biggie.
Oh, and, View Transitions will come Standard in Turbo 8 for full page navigation. Though, we'll take them even a bit further.
Evil Martians & Demoing View Transitions
To use View Transitions, you do not need any external library. But we're going to use one called "turbo view transitions". This came out of a blog series where the author built a neat project called Turbo Music Drive. In two blog posts on Evil Martians, they talk all about morphing and doing crazy stuff with it in Turbo. They even created a live demo!
In the simplest form, view transitions adds a crossfade as you navigate. But you can also get more specific and connect elements between pages to give them a special transition. Watch: when I click this album, it moves up to the left. There's also a nice crossfade for the rest of the page.
Installing turbo-view-transitions
So let's try this out! Step one, get the turbo-view-transitions
library. At your terminal, run:
php bin/console importmap:require turbo-view-transitions
Lovely! To activate transitions, we need to do two things. First, in base.html.twig
, add a meta
tag with name="view-transition"
:
<html> | |
<head> | |
// ... lines 4 - 6 | |
<meta name="view-transition"> | |
// ... lines 8 - 14 | |
</head> | |
// ... lines 16 - 51 | |
</html> |
That's how you tell your browser you want these!
Second, in Turbo 7, we need to activate transitions in JavaScript. Head to app.js
. This will be a rare time when we write JavaScript that doesn't need to live in a Stimulus controller. Copy from their example, paste... and move the import
to the top:
// ... lines 1 - 4 | |
import { shouldPerformTransition, performTransition } from 'turbo-view-transitions'; | |
// ... lines 6 - 9 | |
document.addEventListener('turbo:before-render', (event) => { | |
if (shouldPerformTransition()) { | |
event.preventDefault(); | |
performTransition(document.body, event.detail.newBody, async () => { | |
await event.detail.resume(); | |
}); | |
} | |
}); | |
document.addEventListener('turbo:load', () => { | |
// View Transitions don't play nicely with Turbo cache | |
if (shouldPerformTransition()) Turbo.cache.exemptPageFromCache(); | |
}); |
Done! That should be enough to make the Turbo Drive navigations use view transitions! This leverages an event from Turbo called turbo:before-render
. The shouldPerformTransition()
function checks to see if the user's browser supports transitions. If they don't, it's normal behavior. But if it does, then performTransition()
will transition between the old and new body. There's also a little code down here to avoid the turbo page cache. I think that's something that'll work better in Turbo 8 when this is official.
Time for the big reveal! Hit refresh and watch. Oooooh. A smooth crossfade transition! So not only did we eliminate full page reloads, we now have a transition between our pages. Watch out Powerpoint, we're coming for you!
Transition Turbo Frames
But what about Turbo frames? When we click, that still happens instantly. We activated transitions for full page navigations, but not for frames. Can we? Sure!
Copy this listener, and paste below. This time, listen to turbo:before-frame-render
... and adjust this part. Instead of document.body
, use event.target
. That will be the <turbo-frame>
. And then the new element will be event.detail.newFrame
:
// ... lines 1 - 24 | |
document.addEventListener('turbo:before-frame-render', (event) => { | |
if (shouldPerformTransition()) { | |
event.preventDefault(); | |
performTransition(event.target, event.detail.newFrame, async () => { | |
await event.detail.resume(); | |
}); | |
} | |
}); |
Done! Refresh and.... click. Transition, check!
Debugging Transitions
And if the transition isn't obvious enough, you can open up your browser tools, click the little "...", go to "more tools", then Animations. It looks like I already had it open. Here, you can change the speed of your animations.
Now... it's super obvious. You can even see how it works. If you scroll during the transition, you can kind of see how it takes a screenshot of the old HTML and blends it with the new. This weird effect isn't normally a problem because it happens so fast, but it's cool to see.
Edge Case: Frames that Advance
To show a problem, let's remove the CSS transition on the table that we just added. So spin over to that template... and take of the class
:
// ... lines 1 - 27 | |
{% block body %} | |
<div class="flex"> | |
// ... lines 30 - 36 | |
<section class="flex-1 ml-10"> | |
// ... lines 38 - 56 | |
<turbo-frame id="voyage-list" data-turbo-action="advance"> | |
// ... lines 58 - 134 | |
</turbo-frame> | |
</section> | |
</div> | |
{% endblock %} |
Refresh... and try it. Huh. Nothing happens. I mean, it works... but there was no view transition. Why?
This is subtle. The transition breaks when you have a frame that advances the URL. The problem is that, in this situation, Turbo calls turbo:before-frame-render
... then also calls turbo:before-render
right after. These two, sort of, collide.
But it's an easy fix. Create a variable: let skipNextRenderTransition
and set it to false
. Below, if shouldPerformTransition()
and not skipNextRenderTransition
, then do it:
// ... lines 1 - 9 | |
let skipNextRenderTransition = false; | |
document.addEventListener('turbo:before-render', (event) => { | |
if (shouldPerformTransition() && !skipNextRenderTransition) { | |
// ... lines 13 - 17 | |
} | |
}); | |
// ... lines 20 - 42 |
Finally, when our frame starts its transition, set this variable to true. Also include a setTimeout()
, set that back to false
and delay this for 100 milliseconds:
// ... lines 1 - 25 | |
document.addEventListener('turbo:before-frame-render', (event) => { | |
if (shouldPerformTransition()) { | |
// ... lines 28 - 29 | |
// workaround for data-turbo-action="advance", which triggers | |
// turbo:before-render (and we want THAT to not try to transition) | |
skipNextRenderTransition = true; | |
setTimeout(() => { | |
skipNextRenderTransition = false; | |
}, 100); | |
// ... lines 36 - 39 | |
} | |
}); |
It's a bit weird. But hey, that's programming! We set this to true, Turbo triggers the other listener almost immediately... then 100 milliseconds we reset it. We could probably also replace the setTimeout()
by setting skipNextRenderTransition = false
up in the turbo:before-render
listener.
The most important thing is that... now we have a transition! Let's set that back to full speed. I like it!
Disabling Transitions
Try the planet popover frame. Woh! That was weird. To be fully honest, I do not know what's happening here. For some reason, the view transition causes the popover to disappear... which is... let's say... not ideal. There's probably a way to fix that, but I couldn't crack it.
That's ok... and I think this weirdness is isolated to the popover behavior. Instead, we'll add a way to disable the transitions on a frame.
On the homepage, search for turbo-frame
. Here it is. Add a new attribute called data-skip-transition
:
// ... lines 1 - 27 | |
{% block body %} | |
<div class="flex"> | |
// ... lines 30 - 36 | |
<section class="flex-1 ml-10"> | |
// ... lines 38 - 56 | |
<turbo-frame id="voyage-list" data-turbo-action="advance"> | |
<div class="bg-gray-800 p-4 rounded"> | |
<table class="w-full text-white"> | |
// ... lines 60 - 82 | |
<tbody> | |
{% for voyage in voyages %} | |
<tr class="border-b border-gray-700 {% if loop.index is odd %} bg-gray-800 {% else %} bg-gray-700 {% endif %}"> | |
// ... line 86 | |
<td class="px-2 whitespace-nowrap"> | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
// ... lines 93 - 98 | |
<template data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<turbo-frame data-skip-transition id="planet-card-{{ voyage.planet.id }}" target="_top" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"> | |
// ... lines 107 - 112 | |
</turbo-frame> | |
</div> | |
</template> | |
</div> | |
</td> | |
// ... line 118 | |
</tr> | |
{% endfor %} | |
</tbody> | |
</table> | |
</div> | |
// ... lines 124 - 134 | |
</turbo-frame> | |
</section> | |
</div> | |
{% endblock %} |
I totally made that up. Over an app.js
, we can look for that. If shouldPerformTransition()
and not event.target.hasAttribute('data-skip-transition')
, then do the transition:
// ... lines 1 - 25 | |
document.addEventListener('turbo:before-frame-render', (event) => { | |
if (shouldPerformTransition() && !event.target.hasAttribute('data-skip-transition')) { | |
// ... lines 28 - 39 | |
} | |
}); |
Now... fixed! And we have transitions on... virtually every type of navigation on our site. And in only about 10 minutes! It's crazy!
Now to tomorrow's mission: crafting a dazzling toast notification system that's easy to activate no matter where and how we need to add them. Seeya then!
Hey @weaverryan!
You can leverage that
performTransition
returns aPromise
, so you could reset theskipNestRenderTransition
flag without asetTimeou
callE.g.:
WDYT?