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 SubscribeWhat 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.
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 |
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', () => { | |
this.closeModal(); | |
this.closeSweetalert(); | |
}); | |
document.addEventListener('turbo:render', () => { | |
this.initializeWeatherWidget(); | |
}); | |
document.addEventListener('turbo:visit', () => { | |
// fade out the old body | |
document.body.classList.add('turbo-loading'); | |
}); | |
... 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.
use App\Kernel; | |
require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; | |
sleep(1); | |
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.
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 | |
event.detail.newBody.classList.add('turbo-loading'); | |
}); | |
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(() => { | |
document.body.classList.remove('turbo-loading'); | |
}); | |
}); | |
... 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.
I have a working example of paused rendering and it takes quite a bit.
Here's the core of how you do it:
turbo:before-render
on document
event.preventDefault()
animationend
on the element you're animating in a promiseanimationend
you resolve the promiseevent.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.
Hey Akincer,
I haven't tried it personally, but thank you for sharing this with others!
Cheers!
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.
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.
Pausable rendering has been implemented since this video
https://github.com/hotwired...
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!
Cheers!
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.
Cheers!
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.
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
"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
}
}
// 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
}
}
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