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
akincer Avatar
akincer Avatar akincer | posted 11 months ago | edited

I have a working example of paused rendering and it takes quite a bit.

Here's the core of how you do it:

  1. Create a listener for turbo:before-render on document
  2. In the callback you assign to this listener:
    1. Pause rendering event.preventDefault()
    2. await the result of a promise that executes another function you define
    3. this function returns a promise that creates another listener for animationend on the element you're animating in a promise
    4. In the callback for animationend you resolve the promise
  3. Resume rendering after the await from 2.3 event.detail.resume()

You can see a rough outline of what this looks like here

You can view a library (Turn) that has an implementation here

The Turn library does not fit all situations and didn't work for my needs at all but did point me in the direction of how to get this working. If you don't have a lot of experience with promises this can be frustrating to get working but once you do it's pure magic.

2 Reply

Hey Akincer,

I haven't tried it personally, but thank you for sharing this with others!


akincer Avatar

I'm working on a controller that handles the dirty details that should be fairly adaptable that I'll share once I get it finished. Once you start tinkering with timing of things it gets a little weird and tricky or at least it did for me. The real tricky part is form submissions back to the same page especially if you have a multi-step form or some other step by step interface.

akincer Avatar
akincer Avatar akincer | akincer | posted 11 months ago | edited

One more thing -- if you DO decide to hook into turbo:before-render just know there's a really high if not certain chance that you'll break the back button and maybe even the forward button (haven't tested that). To deal with this you'll want to tap into the popstate event on window so you can set a flag or something in the callback you can check inside your turbo:before-render callback if this event has fired before you pause rendering. This also allows you to get creative with your animations so you can reverse your animations if you so choose to provide a smooth experience.

akincer Avatar

I finally have something I think is ready for people to play with that leverages pausable rendering. As with most things it's a work in progress. The main downside is you have to disable caching.

Hopefully someone finds it useful or educational.

Stimulus Animation Orchestrator

1 Reply
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": ">=8.1",
        "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

What JavaScript libraries does this tutorial use?

// package.json
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.13.13
        "@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
        "@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
        "@popperjs/core": "^2.9.1", // 2.9.2
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
        "@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.3.0
        "bootstrap": "^5.0.0-beta2", // 5.0.1
        "chart.js": "^2.9.4",
        "core-js": "^3.0.0", // 3.13.0
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.2
        "react-dom": "^17.0.1", // 17.0.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
        "stimulus-use": "^0.24.0-1", // 0.24.0-2
        "sweetalert2": "^11.0.8", // 11.0.12
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.2
        "webpack-notifier": "^1.6.0" // 1.13.0