Polished CSS 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

The fade-out and fade-in transition works... until you visit a page that you've already been to... then things get weird. Instead of fading out, it, sort of, fades in... then fades in again?

This happens because, back over in turbo-helper.js, both turbo:before-render and turbo:render happen when both a real page renders and when a preview renders.

... lines 1 - 2
const TurboHelper = class {
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');
});
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-loaded to set the low opacity
// THEN, 10ms later, we remove the turbo-loaded class, which allows the fade in
setTimeout(() => {
document.body.classList.remove('turbo-loading');
}, 10);
});
}
... lines 30 - 61

That means that, when a preview is shown, it gets the same transition effect as a real page. When we click a page we've previously been to, the preview instantly shows - starting faded out - and then fades in. When the Ajax call finishes for the real page, that also starts faded out and then fades in.

Tricky, eh? The solution is to detect if what's rendering is a preview and then do something different. Specifically, if we are rendering a preview, we want to start with full opacity and then fade out, so that we get the same effect as a normal visit.

Detecting if a Preview is Rendering

How do we detect if what's rendering is a preview? By looking for the data-turbo-preview attribute on the html element. Watch, if we go to back to a previous page, watch the html tag. Yup! It has a data-turbo-preview attribute while it's showing.

Back in turbo-helper, start by going all the way to the bottom and creating a new method called isPreviewRendered(). Inside, return document.documentElement - that's how you get the HTML tag - .hasAttribute('data-turbo-preview').

... lines 1 - 73
isPreviewRendered() {
return document.documentElement.hasAttribute('data-turbo-preview');
}
}
... lines 79 - 81

We're using hasAttribute instead of dataset because we don't care what the value is - it would be an empty string - we just care whether or not it exists.

Copy that method name and head back up to our listeners. Start with before:render: if this.isPreviewRendered()... then do nothing for the moment... but in the else, do the normal logic.

Before we add the preview logic, I need to mention that this can be confusing. Because we're inside of before:render, if a preview is being rendered, then it hasn't actually been rendered onto the page yet. Even though that's true, the current page will already have the data-turbo-preview attribute on it, which means we can use our isPreviewRendered() function to figure out if what we're about to render is a preview.

Anyways, if this is a preview, we want to remove the turbo-loading class so that the preview starts at full opacity. Then, one frame later, we want to re-add that class to cause the preview to fade out... because, once the new Ajax call finishes, the real page will fade in.

Copy the code from below, paste, but remove the class. Then, steal the requestAnimationFrame() code, paste that... and grab the classList.add() from below and use that exactly.

... lines 1 - 18
document.addEventListener('turbo:before-render', (event) => {
if (this.isPreviewRendered()) {
// this is a preview that has been instantly swapped
// remove .turbo-loading so the preview starts fully opaque
event.detail.newBody.classList.remove('turbo-loading');
// start fading out 1 frame later after opacity starts full
requestAnimationFrame(() => {
document.body.classList.add('turbo-loading');
});
} else {
// when we are *about* to render a fresh page
// we should already be faded out, so start us faded out
event.detail.newBody.classList.add('turbo-loading');
}
});
... lines 34 - 81

Perfect! So this will remove turbo-loading from the new body, then, one frame later, re-add it to cause the fade out.

Now, in turbo:render, we only want to remove the turbo-loading class if this is not a preview. So if not this.isPreviewRendered(), then remove that turbo-loading class.

... lines 1 - 33
document.addEventListener('turbo:render', () => {
if (!this.isPreviewRendered()) {
// if this is a preview, then we do nothing: stay faded out
// after rendering the REAL page, we first allow the .turbo-loading to
// instantly start the page at lower opacity. THEN remove the class
// one frame later, which allows the fade in
requestAnimationFrame(() => {
document.body.classList.remove('turbo-loading');
});
}
});
... lines 45 - 81

Yes, I know, it's pretty complex. Let's take it for a test drive. Do a full page refresh. If we click to new pages... this all still looks fine. And if we click to a previous page... yes! That did it! The preview instantly shows, fades out, then the new page fades in.

Restore Visits: No Transitions

But... there's one last edge case. Click the "back" button in your browser. Hmm. It instantly goes to low opacity and then fades in. Not terrible... but a little odd. This happens because the snapshot of every page is taken right before the new page is "swapped in". Thanks to our new fade out functionality... it means that snapshots are taken when the page has the turbo-loading class! In other words, snapshots are taken when the page has low opacity! Thanks to this, when the snapshot is restored, it has low opacity. Once the class is removed by our listener code, it fades in.

For me, since clicking back and forward loads instantly, I'd prefer to not have any CSS transition.

How can we do that? When you click back or forward like this, even though it's pulling the page from the snapshot cache, it is not considered a "preview". And so the isPreviewRendered() returns false. That means that we're down in this case. Here, if this is a "restoration" visit - that's what it's called when you click the back or forward buttons in your browser - then we want the new page to start with full opacity and not have a transition.

Check it out: say const isRestoration equals event.detail.newBody.classList.contains('turbo-loading').

That... probably looks a bit confusing. Because of the transition system we just built, every page snapshot will have a turbo-loading class. Since we know this is not a preview, if the body has the turbo-loading class, then this must be a snapshot that's being used for a restoration visit. And if it's a restoration visit, say event.detail.newBody.classList.remove('turbo-loading'). I'll add a note above explaining this. Oh, duh, sorry - this probably looks super confusing because I forgot to wrap this in an if isRestoration. If this is a restoration, remove that class and return.

... lines 1 - 18
document.addEventListener('turbo:before-render', (event) => {
if (this.isPreviewRendered()) {
// this is a preview that has been instantly swapped
// remove .turbo-loading so the preview starts fully opaque
event.detail.newBody.classList.remove('turbo-loading');
// start fading out 1 frame later after opacity starts full
requestAnimationFrame(() => {
document.body.classList.add('turbo-loading');
});
} else {
const isRestoration = event.detail.newBody.classList.contains('turbo-loading');
if (isRestoration) {
// this is a restoration (back button). Remove the class
// so it simply starts with full opacity
event.detail.newBody.classList.remove('turbo-loading');
return;
}
// when we are *about* to render a fresh page
// we should already be faded out, so start us faded out
event.detail.newBody.classList.add('turbo-loading');
}
});
... lines 44 - 91

This will cause the page to start with full capacity and never change.

Phew! Okay, let's make sure this helps. Head back, refresh, click around to a new page, another new page, click to a previous page, and now hit back. Got it! Back and forth show instantly.

Yup, this is tricky. My hope is that CSS transitions will be easier in the future with Turbo. It is doable now, but you do need to keep track of several things.

Organizing our Logic

Before we keep going, let's isolate all of this logic - which is getting kind of big - into its own method. Copy both document.addEventListener() sections, remove them, go down to the bottom, and create a new method called initializeTransitions(). Paste all that logic there, head back up to the constructor and call it: this.initializeTransitions().

... lines 1 - 3
constructor() {
document.addEventListener('turbo:before-cache', () => {
this.closeModal();
this.closeSweetalert();
});
document.addEventListener('turbo:render', () => {
this.initializeWeatherWidget();
});
this.initializeTransitions();
}
... lines 16 - 47
initializeTransitions() {
document.addEventListener('turbo:visit', () => {
// fade out the old body
document.body.classList.add('turbo-loading');
});
document.addEventListener('turbo:before-render', (event) => {
if (this.isPreviewRendered()) {
// this is a preview that has been instantly swapped
// remove .turbo-loading so the preview starts fully opaque
event.detail.newBody.classList.remove('turbo-loading');
// start fading out 1 frame later after opacity starts full
requestAnimationFrame(() => {
document.body.classList.add('turbo-loading');
});
} else {
const isRestoration = event.detail.newBody.classList.contains('turbo-loading');
if (isRestoration) {
// this is a restoration (back button). Remove the class
// so it simply starts with full opacity
event.detail.newBody.classList.remove('turbo-loading');
return;
}
// when we are *about* to render a fresh page
// we should already be faded out, so start us faded out
event.detail.newBody.classList.add('turbo-loading');
}
});
document.addEventListener('turbo:render', () => {
if (!this.isPreviewRendered()) {
// if this is a preview, then we do nothing: stay faded out
// after rendering the REAL page, we first allow the .turbo-loading to
// instantly start the page at lower opacity. THEN remove the class
// one frame later, which allows the fade in
requestAnimationFrame(() => {
document.body.classList.remove('turbo-loading');
});
}
});
}
... lines 92 - 95

This at least gives all this code down here a name so that future "us" can better remember what it does.

Oh, and while we're cleaning things up, don't forget to take the sleep out of public/index.php ... and inside of styles/app.css, change the transition to something more realistic, like 200 milliseconds. Also change the opacity to something less extreme, like .8.

Let's see what this - more "real-world" - setup looks like. The reload is faster and the transition is... a nice, subtle effect! If we click to a preview page, that's good... and hitting back also works.

Next: let's try something kind of crazy. What if, when a user hovers over a link, we prefetch that URL so that Turbo can display it even faster. This little trick - which is super fun with Turbo - can actually be used to speed up any site.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "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.13.3
        "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
    }
}