Skeleton screens, but fast
Heres a fun HTTP+HTML+CSS technique for skeleton screens that works in almost* any stack, and some small but important details we need to do right by it.
Most importantly, it involves no client-side JavaScript, because adding JS to make a website feel faster usually is counter-productive. In fact, Zach Leatherman inspired this post by saying:
Zach Leatherman@zachleatFor meI dont think this is worth it. Not if it has a JS cost.
Maybe only for huge images (not on by default) and definitely *not* animating it.
Either way, @cramforce has a nice No-JS blur-up demo in his blog post industrialempathy.com/posts/image-op17:57 PM - 21 Jan 2021
* The newer isomorphic ones like React struggle mightily to stream over HTTP, with one exception Ill get to it later.
Skeleton screens?
Or indicators/placeholders/whatever. The new design hotness for when computers arent ready to show you something: skeleton screens!
No, not nearly that entertaining.Instead of a spinner or progress bar, show something shaped like the eventual content it orients the user faster, hints at what to expect, and avoids the page jumping around as it loads:
The loading process of Polar, one of the first apps to popularize the concept of skeleton screens.Demo
We cant avoid the time it takes to call a search results API we can cache its responses, but how can you cache all possible search queries ahead of time?
Heres what these search skeletons look like with an artificial search API response delay of 5 seconds:
And heres some code for how they work:
<SiteHead /><h1>Search for ${searchQuery}</h1><div.SearchSkeletons> <await(searchResultsFetch)> <!-- stalls the HTML stream until the API returns search results --> <@then|result|> <for|product| of=result.products> <ProductCard product=product /> </for> </@then> </await></div>
This is that one exception I mentioned earlier. Marko is a JS component framework similar to React, but is actually good at server-side rendering in particular, built-in support for HTTP streaming. (And last I checked, its nearly the only thing in Node that does. RIP Dust)
If youre more familiar with other languages/frameworks, heres how they accomplish something similar to Markos <await>
:
- PHP
flush()
andob_flush()
- Ruby on Rails
ActionController::Streaming
- Spring
StreamingResponseBody
- ASP.net
- I recommend searching for ASPs
.BufferOutput
and.Flush()
yourself, because itll also turn up results warning about possible footguns. - Django
- Theres a
StreamingResponseBody
, but Django really doesnt care for it. You may need to get creative. - Others not listed here
- Try searching for them plus http stream or chunked transfer-encoding.
By not waiting on search results before sending HTML, browsers get a head start downloading assets, booting JS, calculating styles, and showing the <SiteHeader>
and <h1>
.
.SearchSkeletons:empty { height: 110vh; /* Skeletons take up at least the full viewport */ background-image: /* Assume this is an image of the skeletons for now */;}.SearchSkeletons::before { /* This is the faded white bar that scrubs across the skeletons */ content: ""; position: absolute; height: 100%; width: 3rem; background: linear-gradient(rgba(white, 0) 10%, rgba(white, 0.5), ); animation: shimmer 2.5s linear infinite;}@keyframes shimmer { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) }}
The :empty
pseudo-class is the key:
While waiting for the search API, the opening
<div class="SearchSkeletons">
is streamed to browsers, without children or a closing tag.:empty
only selects elements without children, such as the aforementioned<div>
.As soon as the HTML resumes streaming and fills
.SearchSkeletons
with results,:empty
no longer applies.The skeleton styles disappear at the same time the
<ProductCard>
components display, reanimating the product skeletons into real products.
A nice thing about this approach is that if the search endpoint responds quickly, :empty
never matches and browsers waste no resources styling or displaying the product skeletons.
Avoiding style recalculation
Do we need :empty
? Couldnt this also work?
<SiteHead /><h1>Search for ${searchQuery}</h1><style> .SearchSkeletons { }</style><div.SearchSkeletons></div><await(searchResultsFetch)> <@then|result|> <style> .SearchSkeletons { display: none } </style> <for|product| of=result.products> <ProductCard product=product /> </for> </@then></await>
Yes, that does work. But its slower: appending new CSS to a document triggers style recalc, where browsers update their selector buckets, invalidate and re-match elements, etc.
We cant avoid browsers performing reflow, as that always happens when new HTML streams in. But by avoiding additional style recalc:
- Browsers show the new HTML sooner
- User interaction doesnt hitch as much
- Theres more CPU time left over to run JavaScript
Using :empty
vs. additional <style>
elements is a subtle decision, but it impacts user experience just the same.
Hopefully, this illustrates why a strong understanding of HTML and CSS is important for making a site fast.
Hardware-accelerated animation or bust
And if that didnt illustrate why a strong understanding of HTML and CSS is important for making a site fast, this sure as hell will.
A predefined @keyframes
that only changes the transform
property is one way to ensure that an animation is hardware-accelerated on the GPU. That means it frees up the CPU for all the other responsibilities of the main thread: parsing, JavaScript, user interaction, reflow
Skeleton animations that run on the main thread have a raft of complications:
- The shimmer animation will hiccup and stall whenever JavaScript executes, the document reflows, style recalculates, or JSON is parsed.
- The time the CPU spends running the animation makes the above tasks take longer.
- The loading indicator delays the content its a placeholder for!
At my job, I changed a similar loading animation from using background-position
to transform
. The page FPS went from 49 to 55 on a powerful developer MacBook imagine how much more on mobile!
But wait, theres more!
Remember this from the earlier code sample?
background-image: /* Assume this is an image of the skeletons for now */;
The TL;DR is the background image is made of CSS gradients and so that the skeleton is shown ASAP. It makes no sense to have your loading indicator wait on an HTTP request, does it?
I implemented the background images with Sass variables to prevent the skeletons from drifting out of sync with the product cards if any changes were made. For example, if I tweaked the padding of the actual product cards, the following code would also update the spacing of the skeletons:
$skeleton-color: #dfe1e1;$card-padding: 0.5rem;$card-height: 8.125rem;$img-height: 70%;$img-width: 45%;$img-position: right $card-padding top 0;$img-skeleton: linear-gradient( transparent 15%, $skeleton-color 15%, $skeleton-color ($img-height + 15%), transparent ($img-height + 15%));$name-line-size: 0.844rem;$name-line-1-width: 13ch;$name-line-1-offset: $card-padding;$name-line-1-position: $card-padding 0;$name-line-1-skeleton: linear-gradient( transparent $name-line-1-offset, $skeleton-color $name-line-1-offset, $skeleton-color ($name-line-1-offset + $name-line-size), transparent ($name-line-1-offset + $name-line-size));$name-line-2-width: 10ch;$name-line-2-offset: $card-padding + $name-line-size + 0.2rem;$name-line-2-position: $card-padding 0;$name-line-2-skeleton: linear-gradient( transparent $name-line-2-offset, $skeleton-color $name-line-2-offset, $skeleton-color ($name-line-2-offset + $name-line-size), transparent ($name-line-2-offset + $name-line-size));$price-height: 1.5rem;$price-width: 4ch;$price-offset: $name-line-2-offset + 1.3rem;$price-position: $card-padding 0;$price-skeleton: linear-gradient( transparent $price-offset, $skeleton-color $price-offset, $skeleton-color ($price-offset + $price-height), transparent ($price-offset + $price-height));.SearchSkeletons:empty { background-repeat: repeat-y; background-image: $img-skeleton, $name-line-1-skeleton, $name-line-2-skeleton, $price-skeleton ; background-size: $img-width $card-height, $name-line-1-width $card-height, $name-line-2-width $card-height, $price-width $card-height ; background-position: $img-position, $name-line-1-position, $name-line-2-position, $price-position ;}@media (min-width: 30rem) { .SearchSkeletons { display: grid; grid-template-columns: repeat(auto-fill, minmax(20rem, 0.75fr)); grid-gap: 1rem 2rem; justify-content: center; &:empty { /* TODO show how to use `background-repeat-x: round` to make skeletons responsive */ height: auto; background: none; } }}
Heres what that Sass compiles to:
.SearchSkeletons:empty { background-repeat: repeat-y; background-image: linear-gradient(transparent 15%, #dfe1e1 0, #dfe1e1 85%, transparent 0), linear-gradient(transparent .5rem, #dfe1e1 0, #dfe1e1 1.344rem, transparent 0), linear-gradient(transparent 1.544rem, #dfe1e1 0, #dfe1e1 2.388rem, transparent 0), linear-gradient(transparent 2.844rem, #dfe1e1 0, #dfe1e1 4.344rem, transparent 0); background-size: 45% 8.125rem, 13ch 8.125rem, 10ch 8.125rem, 4ch 8.125rem; background-position: right .5rem top 0, .5rem 0, .5rem 0, .5rem 0;}
I was going to finish this post with how to make these mobile-first styles responsive using background-repeat
, but it was making me put off publishing this altogether, and thats terrible. If youre interested, let me know and Ill write a followup.
Original Link: https://dev.to/tigt/skeleton-screens-but-fast-48f1
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To