Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

CSS Page 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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

What about CSS transitions between pages as we click around? This is something that a competing library called Swup does very well. But in Turbo, it's not so easy. Well, it will be easier once a PR is merged into Turbo.

Here's the basic problem: when you click, Turbo makes an Ajax call for the new HTML. Then, when that Ajax call finishes, it immediately puts the new body onto the page. To be able to have a CSS transition between visits, we need a way to pause that process. When the Turbo Ajax call finishes, we need to be able to tell Turbo to not immediately render the new page so that we can instead start a CSS transition - like fading out the old page. Then, once that transition finishes, we tell Turbo to finally finish its job of putting in the new body.

The missing piece right now, which the pull request addresses, and which has gotten a thumbs up from the maintainers, is that there's no ability to pause that process. If you're interested in complex CSS transitions, keep an eye on this issue.

Does this mean that we can't add any transitions? Actually, no! It just means we can't create super-precise animations. For example, imagine that we want to slide the old content up, wait for that transition to finish, then immediately slide the new content down. That's not going to work until we have more control over the process.

But if we just want to fade out the old page and fade in the new page, that will work. Why? Because if the fade out doesn't quite finish before the fade in starts... that's probably not a huge deal. It's a little imprecise, but it will still look good. So even though we can't add perfect CSS transitions yet, let's learn how to do this. It's a fascinating example of the power of Turbo events.

So here's the plan: at various times while the old page is leaving and the new page is entering, we're going to add some CSS classes that allow us to cause those to fade out and fade in.

Adding the Transition CSS

Let's actually start with the CSS. Open up assets/styles/app.css. Right on top inside body, add transition: opacity 1000ms.

Two things about this. First, 1000 milliseconds is way too long for a transition, but it'll make this easy for us to see while we're developing. Second, if you're new to CSS transitions, this line doesn't cause a transition. It just says that if the opacity of the body ever changes, I want it to change gradually over one second, instead of immediately.

Below this, add body.turbo-loading. Inside, set the opacity to .2... which is probably too low of an opacity for a nice effect... but again, it'll make it easy for us to see.

This turbo-loading class is not something that's part of Turbo: it's something that we are going to add to cause the transition.

... lines 1 - 4
body {
font-family: 'Montserrat', sans-serif;
transition: opacity 1000ms;
body.turbo-loading, body.turbo-loaded {
opacity: .2;
... lines 12 - 173

Triggering the Fade Out Transition

Let's do it. Go back to assets/turbo/turbo-helper.js and, in the constructor, here we are, add a new event listener at the bottom. Step one is, when we click a link, we want to add the turbo-loading class to the <body>. That will cause the old body to fade out.

Do that with document.addEventListener() and, this time, listen to an event called turbo:visit. This is yet another event that we haven't seen before. This is triggered immediately after a visit starts. Inside, say document.body - that's an easy way to get the body element - then .classList.add('turbo-loading'). I'll add a comment that explains what this does.

... lines 1 - 3
constructor() {
document.addEventListener('turbo:before-cache', () => {
document.addEventListener('turbo:render', () => {
document.addEventListener('turbo:visit', () => {
// fade out the old body
... lines 18 - 61

To make it easy to see if this is working, go to public/index.php... and add a 1 second sleep() temporarily.

11 lines public/index.php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

Ok: let's go refresh the page... this will be kind of slow. Ready? Click! Nice! The page faded out. But then the new content shows up immediately. We haven't added the fade in effect yet.

Triggering the Fade In Transition

Let's do that. Head back to turbo-helper.js. I'm going to paste in two more listener functions. Let's walk through this: we've seen both of these events before.

... lines 1 - 17
document.addEventListener('turbo:before-render', (event) => {
// when we are *about* to render, start us faded out
document.addEventListener('turbo:render', () => {
// after rendering, we first allow the turbo-loading class to set the low opacity
// THEN, one frame later, we remove the turbo-loading class, which allows the fade in
requestAnimationFrame(() => {
... lines 29 - 61

turbo:before-render fires right before the new body is added to the page. This allows us to add the turbo-loading class to the new body before it's added to the page. This will set its opacity to .2 to start: we want it to start faded out.

Then the turbo:render event is triggered right after that new body is added to the page. Here, we want to remove the turbo-loading class. That will set the opacity back to 1... and thanks to the transition, it should happen slowly over 1 second.

But we can't remove the class immediately... we can't just put this line directly here in the listener. Why not? We need the new body to be rendered for at least 1 "frame" with the lower opacity... so with the turbo-loading class. If we remove it immediately - by just putting the line right here - the element will actually start at full opacity with no transition... because it never got the chance to render with the low opacity.

This is why we have this requestAnimationFrame() function. This is a built-in browser function that says:

Hey, once you do render the next frame, please call this function.

This allows the element to be rendered for one frame with the low capacity... and then we remove the class to transition to full opacity. Pretty freaking cool.

Let's try it. Refresh, and... click. Yes! The fade out and fade in transition looks perfect! Yay! Until... we visit a page we've already been to. Woh. That was weird. It... sort of faded in and... then faded in again?

Let's find out what's going on next and use more Turbo smartness to fix it. By the end, we are going to have perfect fade transitions.

Leave a comment!

Login or Register to join the conversation
Nick F. Avatar

Pausable rendering has been implemented since this video

I'm going to try to implement it on my own but a video on integrating Swup with Turbo would be awesome


Yes, I need to try this :).

> but a video on integrating Swup with Turbo would be awesome

It's not that you would integrate Swup WITH Turbo... more that you would take advantage of the "pausable" rendering in order to do Swup-like transitions in Turbo (taking inspiration from Swup).

Anyways, if you have any luck, I'd love to know!


Nick F. Avatar

Yeah... it looks like swup and turbo do almost the same exact thing: turn all the links in the body to ajax calls. So, right now it looks to me like there's no point in using both together, it's either one or the other. Or if there's some other capabilities that only Turbo has, then I can just disable turbo-drive and use the other stuff. I just really want swup, it looks sick
But, I've still got a ways to go in this tutorial so we'll see


Yea, that's the draw of Swup as I understand it - you get those slick transitions out of the box. With Turbo, they appear to now be possible, but you would likely be rolling them manually :).

Turbo Drive and Swup are, as you said, effectively the same in what they do. The "extra" that Turbo has (that I don't think Swup has) is the "Frames" and "Streams" systems, if you need those.


Nick F. Avatar

Yes, I just finished the course and my mind is blown by the possibilities of frames and streams.
I haven't dug too deep into swup yet but it seems like swup containers may be comparable to frames.
I'm doing a lot of backend stuff right now but I can't wait to get another full-stack project to try it out.

Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "", //
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.18.5
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0