Designing loading states on the web is often overlooked or dismissed as an afterthought. Performance is not only a developer’s responsibility, building an experience that works with slow connections can be a design challenge as well.
While developers need to pay attention to things like minification and caching, designers have to think about how the UI will look and behave while it is in a “loading” or “offline” state.
The illusion of Speed
As our expectations for mobile experiences change, so does our understanding of performance. People expect web apps to feel just as snappy and responsive as native apps, regardless of their current network coverage.
Perceived performance is a measure of how fast something feels to the user. The idea is that users are more patient and will think of a system as faster if they know what’s going on and can anticipate content before it’s actually there. It’s a lot about managing expectations and keeping the user informed.
For a web app, this concept might include displaying “mockups” of text, images or other content elements – called skeleton screens 💀. You can find these in the wild, used by companies like Facebook, Google, Slack and others:


An Example
Say you are building a web app. It’s a travel-advice kind of thing where people can share their trips and recommend places, so your main piece of content might look something like this:

You can take that card and reduce it down to its basic visual shapes, the skeleton of the UI component.

Whenever someone requests new content from the server, you can immediately start showing the skeleton, while data is being loaded in the background. Once the content is ready, simply swap the skeleton for the actual card. This can be done with plain vanilla JavaScript or using a library like React.
Now you could use an image to display the skeleton, but that would introduce an additional request and data overhead. We’re already loading stuff here, so it’s not a great idea to wait for another image to load first. Plus it’s not responsive, and if we ever decided to adjust some of the content card’s styling, we would have to duplicate the changes to the skeleton image so they’d match again. 😒 Meh.
A better solution is to create the whole thing with just CSS. No extra requests, minimal overhead, not even any additional markup. And we can build it in a way that makes changing the design later much easier.
Drawing Skeletons in CSS
First, we need to draw the basic shapes that will make up the card skeleton. We can do this by adding different gradients to the background-image
property. By default, linear gradients run from top to bottom, with different color stop transitions. If we just define one color stop and leave the rest transparent, we can draw shapes.
Keep in mind that multiple background-images are stacked on top of each other here, so the order is important. The last gradient definition will be in the back, the first at the front.
.skeleton {
background-repeat: no-repeat;
background-image:
/* layer 2: avatar */
/* white circle with 16px radius */
radial-gradient(circle 16px, white 99%, transparent 0),
/* layer 1: title */
/* white rectangle with 40px height */
linear-gradient(white 40px, transparent 0),
/* layer 0: card bg */
/* gray rectangle that covers whole element */
linear-gradient(gray 100%, transparent 0);
}
These shapes stretch to fill the entire space, just like regular block-level elements. If we want to change that, we’ll have to define explicit dimensions for them. The value pairs in background-size
set the width and height of each layer, keeping the same order we used in background-image
:
.skeleton {
background-size:
32px 32px, /* avatar */
200px 40px, /* title */
100% 100%; /* card bg */
}
The last step is to position the elements on the card. This works just like position:absolute
, with values representing the left
and top
property. We can for example simulate a padding of 24px for the avatar and title, to match the look of the real content card.
.skeleton {
background-position:
24px 24px, /* avatar */
24px 200px, /* title */
0 0; /* card bg */
}
Break it up with Custom Properties
This works well in a simple example – but if we want to build something just a little more complex, the CSS quickly gets messy and very hard to read. If another developer was handed that code, they would have no idea where all those magic numbers are coming from. Maintaining it would surely suck.
Thankfully, we can now use custom CSS properties to write the skeleton styles in a much more concise, developer-friendly way – and even take the relationship between different values into account:
.skeleton {
/*
define as separate properties
*/
--card-height: 340px;
--card-padding:24px;
--card-skeleton: linear-gradient(gray var(--card-height), transparent 0);
--title-height: 32px;
--title-width: 200px;
--title-position: var(--card-padding) 180px;
--title-skeleton: linear-gradient(white var(--title-height), transparent 0);
--avatar-size: 32px;
--avatar-position: var(--card-padding) var(--card-padding);
--avatar-skeleton: radial-gradient(
circle calc(var(--avatar-size) / 2),
white 99%,
transparent 0
);
/*
now we can break the background up
into individual shapes
*/
background-image:
var(--avatar-skeleton),
var(--title-skeleton),
var(--card-skeleton);
background-size:
var(--avatar-size),
var(--title-width) var(--title-height),
100% 100%;
background-position:
var(--avatar-position),
var(--title-position),
0 0;
}
Not only is this a lot more readable, it’s also way easier to change some of the values later on. Plus we can use some of the variables (think --avatar-size
, --card-padding
, etc.) to define the styles for the actual card and always keep it in sync with the skeleton version.
Adding a media query to adjust parts of the skeleton at different breakpoints is now also quite simple:
@media screen and (min-width: 47em) {
:root {
--card-padding: 32px;
--card-height: 360px;
}
}
Browser support for custom properties is good, but not at 100%. Basically, all modern browsers have support, with IE/Edge a bit late to the party. For this specific use case, it would be easy to add a fallback using Sass variables.
Add Animation
To make this even better, we can animate our skeleton, and make it look more like a loading indicator. All we need to do is put a new gradient on the top layer and then animate its position with @keyframes
.
Here’s a full example of how the finished skeleton card could look:
Skeleton Loading Card by Max Böck (@mxbck) on CodePen.
You can use the :empty
selector and a pseudo element to draw the skeleton, so it only applies to empty card elements. Once the content is injected, the skeleton screen will automatically disappear.
More on Designing for Performance
For a closer look at designing for perceived performance, check out these links:
- Designer VS. Developer #8: Designing for Great Performance
- Vince Speelman: The Nine States of Design
- Harry Roberts: Improving Perceived Performance with Multiple Background Images
- Sitepoint: A Designer’s Guide to Perceived Performance
- Manuel Wieser: Dominant Color Lazy Loading
If you don’t have a defined width and height, should you still define at least a height, for responsive? And then just let the rest get hidden (cut off) when sized smaller?
Or would it be better to adjust for each media query?
Pretty neat to use :empty, love that.
Hi Chris!
You sort of need the height to calculate the background-position, but you can easily replace the fixed width with percentages. Or you could do something like
calc(100% - var(--card-padding) * 2)
to “fake” block level elements.check out this quick fork for a responsive version:
@Max Very Nice! I might have to try this technique for the web app I am developing, we have a few Ajax calls and it would be great for that. Thank you for the article and follow up codepen!
Very well explained. Thanks for sharing this article.
While I like the concept of skeleton elements, I’ve always felt like if you need animation in one, your “perceived” performance is still terrible since users are still waiting a noticeable amount of time for actual content. I always try to get backend responses in under 100ms for any JavaScript action.
@Max thanks for the great snippet. I will be using it in some upcoming projects.
awesome! glad it’s useful to you.
You don’t need an extra pseudo-element, like ::after, when using the :empty pseudo-class. As long as the container has no child nodes of any kind, the selector will match and you can style the container’s background with the skeleton screen, thus removing needless complexity.
I built this same demo more than a year ago: https://codepen.io/oslego/pen/XdvWmd
Also, you should explain the 99% hack in the color stop for the radial gradient. It’s no longer necessary in Chrome (bug fixed) but people will be confused.
While the animation is cool, I’d recommend avoiding it, particularly for large surface gradients. It causes constant repaints in the browser, thus degrading perf. To check: open Chrome DevTools, switch to the Rendering panel (next to bottom Console) and tick the checkbox labeled “Paint flashing”.
@Razvan and/or @Max,
First of all – thanks, this is *awesome.
Secondly – would anyone be able to explain/link to an explanation of the 99% hack, some document about the bug fix, or just what’s going on there?
Hi Schuyler,
Basically, you want the radial gradient to have crisp edges, as opposed to the default blending it does.
As Razvan correctly pointed out, there was a bug in Chrome (and it’s still present in Firefox and Safari) where radial-gradients with a 100% and a transparent 0 color stop would not render as circles, but fill the entire space. The 99% hack is a way to get around that.
You could also write it as
radial-gradient(circle $radius, $color 99%, transparent 100%)
. It’s not perfectly smooth at the edges, but close enough ;)Ohhh, got it. Thanks for the explanation!
I really got the concept but the animation part wasn’t well explained. May be the writer assumed that it’s audience knew the animation.
While trying to understand the keyframe section I realized that css variables are problem solvers but some time you need the exact without the need of locking the variables.
Awesome explanation. This is such a great demonstration of how several simple things (multiple backgrounds, css properties, simple transition and animations) combined can create an impression solution.
I change the value of this for smoother animation instead of looks jaggy. Thanks for the
background-position:
-200% 0
But it’s a losing game, isn’t it?
The tricks we use to create a perception of something loading become stale and the trick itself becomes as annoying as the original wait time.
We introduced spinners and progress bars to create a perception of activity for the user instead of presenting them with a blank space. But now the spinner isn’t enough. Why is that? Simple. Users got used to the spinner and realised it was a bright and shiny thing designed to distract them. The spinner and the blank screen became one and the same.
And the same will happen with skeleton screens. They are fairly common but who looks at this effect and doesn’t realise what’s going on?
The only thing that’ll ever be meaningful is actual content. Perhaps the page could load something in the background that is instantly displayed while the user is waiting. Seems like a good opportunity to provide promotional info or a call to action for the user.
Or just stick with the spinner.
Perception is reality. This mimics the shape of content so it is better than a spinner (for now). When you’re waiting on a friend to pick you up, people always look down the road to see the car before it gets to them instead of just watching their watch.
Hi Nuwanda,
You’re right, the concept of skeleton screens isn’t that new – I think most people have seen it somewhere, e.g. Facebook. Nobody is actually “tricked” into thinking that this is not a loading state – it is just a type of spinner that conveys a little more information.
Nevertheless, our brains work in such a way that anticipation of content feels better than staring at a spinning circle. Thats the whole point of perceived performance.
This is of course no excuse to skip actual performance optimization – if you can cache meaningful content and display that – great!