Here’s 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:
* The newer isomorphic ones like React struggle mightily to stream over HTTP, with one exception — I’ll get to it later.
Skeleton screens?
Or indicators/placeholders/whatever. The “new” design hotness for when computers aren’t ready to show you something: skeleton screens!
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:
Demo
We can’t 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?
Here’s what these search skeletons look like with an artificial search API response delay of 5 seconds:
And here’s 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, it’s nearly the only thing in Node that does. RIP Dust)
If you’re more familiar with other languages/frameworks, here’s how they accomplish something similar to Marko’s <await>
:
- PHP
-
flush()
andob_flush()
- Ruby on Rails
-
ActionController::Streaming
- Spring
-
StreamingResponseBody
- ASP.net
- I recommend searching for ASP’s
.BufferOutput
and.Flush()
yourself, because it’ll also turn up results warning about possible footguns.
- Django
-
There’s a
StreamingResponseBody
, but Django really doesn’t 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(100vw) }
}
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
? Couldn’t 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 it’s slower: appending new CSS to a document triggers “style recalc”, where browsers update their selector buckets, invalidate and re-match elements, etc.
We can’t 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 doesn’t hitch as much
- There’s 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 didn’t 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 it’s 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, there’s 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;
}
}
}
Here’s 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 that’s terrible. If you’re interested, let me know and I’ll write a followup.
Update
I haven’t written that followup yet, but Jason “scurker” shared that the AXE browser extension does this too, and was able to share some code for how they do repeating skeleton backgrounds:
Top comments (4)
My god, its been so long since I read an article that felt it written by a human, an organic being, not a cyborg
Truly, truly loved your writing style and the whimsy stuff. I try that myself on my blog posts but it always comes off as lame. But this, this is hot stuff 🔥🔥
PS: Your article was mindblowingly educative, and I'm bookmarking it somewhere I can refer to in future. Keep kicking a**!!
Love your engaging writing style...
I'm pretty sure that if my history professor lectured like this I'd score top marks...
My goodness yes please on background repeat. I have an upcoming project that would benefit from it. Make it happen 😂. And thanks for this great article.
I haven’t gotten to it yet, but I updated with an addendum with how the AXE team does it