Skeleton screens, but fast

Taylor Hunt - Apr 28 '21 - - Dev Community

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!

Screens depicting skeletons?

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 Polar app used a skeleton screen for the name, image, and profile info of its user details page.

The loading process of Polar, one of the first apps to popularize the concept of skeleton screens.

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>
Enter fullscreen mode Exit fullscreen mode

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() and ob_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) }
}
Enter fullscreen mode Exit fullscreen mode

The :empty pseudo-class is the key:

  1. While waiting for the search API, the opening <div class="SearchSkeletons"> is streamed to browsers, without children or a closing tag.

  2. :empty only selects elements without children, such as the aforementioned <div>.

  3. As soon as the HTML resumes streaming and fills .SearchSkeletons with results, :empty no longer applies.

  4. 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>
Enter fullscreen mode Exit fullscreen mode

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 */;
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . .
Terabox Video Player