Turbolinks provides countless websites and apps with the feeling and performance of a Single Page Application, without the baggage that a front end framework can bring. As Turbolinks has control over page loads, we’ve been looking into how it can be used to provide smooth animations between pages. This article will give a brief overview of what Turbolinks does, when it does it and how to create simple page animations using its events.
What does Turbolinks do?
A traditional page request would see the browser tear down the current page, then request the new page along with all the assets, such as JS & CSS files again. These assets can often prove to be the culprits of slow page load times.
Put simply, Turbolinks instead hijacks all links, stops the browser performing a request, then sends its own XHR request. It then replaces the old body with the new and merges the head tags together. This means your CSS & JS files will persist between pages. It also caches pages for even faster load times and provides us with a CSS based loading bar at the top of the page.
Looking for expert guidance or developers to work on your project? We love working on existing codebases – let’s chat.
Turbolinks events
To make effective use of Turbolinks, it’s important to understand the lifecycle of page loads and the events it provides. There are 3 variations of page loads we need to know.
Note: This is a reworking of the diagrams from this blogpost https://sevos.io/2017/02/27/turbolinks-lifecycle-explained.html
Initial page load
When you first visit a page only the turbolinks:load
event fires. This applies for all page loads. This event can be used as a replacement for the DOMContentLoaded
or jquery ready
events.
Visiting an uncached
Let’s say we click a link to go to page 2…
This time a lot more events are fired. The ones that are particularly useful are marked with an asterisk.
:before-visit
can access the element that triggered the page load and can cancel the new page load:before-cache
is where you need to reset the page for when it is served from the cache (e.g emptying form fields):before-render
can access the new body whilst you still have access to the current body
Visiting a cached page
Now if we click a link to a previously visited page that’s been cached
Now you’ll notice :before-cache
is called earlier. This is because a cached version of the page is rendered whilst the XHR request is completing. Once this request is complete the page is replaced again. This results in a pair of :before-render
& :render
events firing.
Creating page animations
Animating elements on page load will be left out from this post. This can be done using CSS only, or can be applied on the turbolinks:load
event. Animating elements when leaving a page can be a bit trickier as we need to delay the page navigation until after our animations have finished.
The goal here is to add a custom data attribute to an element that has a value of an animation class. We can then add that value as a class to the element when we need.
<h1 "data-animate-out"="animate-slide-down">Homepage</h1>
And the CSS class itself
.animate-slide-down { animation: slide-down 0.4s ease; animation-fill-mode: forwards; } @keyframes slide-down { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(20px); } }
Now for the Javascript. For brevity we’ll use jQuery.
function setupPageAnimations() { // State variable let _isAnimating = false; // Create list of nodes to animate let elementsToAnimate = []; // Events for browser compatibility const eventsPrefixed = ['animationend', 'webkitAnimationEnd', 'oAnimationEnd', 'MSAnimationEnd']; $(document).on('turbolinks:before-visit', e => { // Prevent an infinite loop if (!_isAnimating) { // Get the first element to animate const firstEl = $('[data-animate-out]')[0]; let isAnimationEventSupported; // Check if the browser supports animationend // event, if it does... $(eventsPrefixed).each((ind, event) => { if (`on${event}` in firstEl ) { isAnimationEventSupported = true; return false; } }); // ...we can begin animating if (isAnimationEventSupported) { _isAnimating = true; // Prevent default navigation e.preventDefault(); // Get the new url const newUrl = event.data.url; // Push elements that need animating to an array $('[data-animate-out]').each((ind, el) => { elementsToAnimate.push(el); }); // Animate the list runAnimations(elementsToAnimate, eventsPrefixed); // Once all animations are complete... $(document).one('allAnimationEnd', () => { if (_isAnimating) { // Start the new page load Turbolinks.visit(newUrl); // Reset variables elementsToAnimate = []; _isAnimating = false; } }); } } }); }
It’s important here to check whether we’re already animating. After all we’re preventing navigation which needs to be done with caution. We then check whether the browser supports the animationend
event.
After creating the list of elements to animate, we’ll have a separate function that will fire a custom event (allAnimationEnd
) once all animations have finished. We can then hook onto that to visit the new page & reset our variables. Note that the Turbolinks.visit()
method will fire the Turbolinks event cycle again so another if check is made to avoid an infinite loop.
Now for the animation function.
function runAnimations(elList, eventsPrefixed) { let animationsFinished = 0; const totalAnimations = elList.length; $(elList).each((ind, el) => { // Once each animation has finished $(el).one( eventsPrefixed.join(' '), () => { animationsFinished++; // Check if they're all finished checkForAllFinished(el); } ); // Fire animation, // adding attribute value as a class $(el).addClass($(el).attr('data-animate-out')); }); function checkForAllFinished(el) { if (animationsFinished === totalAnimations) { // Dispatch custom event once all animations // have finished const event = new CustomEvent(`allAnimationEnd`, { bubbles: true, cancelable: true }); el.dispatchEvent(event); } } }
Now we’ve created the custom event that will trigger the page load, our animations are working.
We now have 2 problems though.
When navigating back to these pages internally, a cached version will be shown with the animation classes applied so elements won’t be visible. Also, we haven’t accounted for animations being interrupted by browser navigation.
$(document).on('turbolinks:before-cache', () => { removeAnimateClasses(); }); $(window).on('popstate', e => { if (_isAnimating) { // Prevent loading previous page // if animations have started e.preventDefault(); history.go(1); removeAnimateClasses(); // Reset variables _isAnimating = false; elementsToAnimate = []; } }); // Use regex to remove all animation classes function removeAnimateClasses() { const els = $('[data-animate-out], [class*=animate-]'); $(els).removeClass((index, className) => { return (className.match(/(^|\s)animate-\S+/g) || []).join(' '); }); }
With the popstate
event, we’re assuming the user has clicked ‘back’ whilst our animations are running. The default behaviour would be to go to the previous page but in our case, we want to reset the current page (this is a matter of preference though).
With the clean up code in place, we have a solid foundation for our page animations. Due to animationend
support, these animations work in: Edge 79+, Firefox 74+, Chrome 24+, Safari 9.1+, Opera 66+.
Conclusion
Turbolinks’ priority is speed, not animations. However with a proper understanding of how Turbolinks works, it’s perfectly possible to incorporate animations without sacrificing all the benefits it provides. And we didn’t have to use any other libraries/frameworks for the task!
The sky’s now the limit (nearly). To take things further you could use animation delays, fancy loading screens or blend pages into one another.
We’ve put the source code for this example on Github.
If you have any comments, suggestions or other animations methods, let me know!