Loaders and skeletons
Six loading indicators in pure CSS, plus the skeleton pattern that usually beats all of them.
<div class="shelf">
<figure><div class="spinner"></div><figcaption>Spinner</figcaption></figure>
<figure><div class="dots"><i></i><i></i><i></i></div><figcaption>Dots</figcaption></figure>
<figure><div class="bar"></div><figcaption>Indeterminate bar</figcaption></figure>
<figure><div class="pulse"></div><figcaption>Pulse</figcaption></figure>
<figure><div class="ring"></div><figcaption>Progress ring</figcaption></figure>
<figure><div class="bars"><i></i><i></i><i></i><i></i></div><figcaption>Bars</figcaption></figure>
</div>
<h3>Skeleton screen</h3>
<div class="skeleton" aria-busy="true" aria-live="polite" aria-label="Loading article">
<div class="sk sk-img"></div>
<div class="sk sk-line w70"></div>
<div class="sk sk-line w90"></div>
<div class="sk sk-line w50"></div>
</div>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 24px; background: #fff; }
.shelf { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin-bottom: 28px; }
figure { margin: 0; display: grid; place-items: center; gap: 10px; padding: 18px 10px;
border: 1px solid #e2e7ef; border-radius: 10px; }
figcaption { font-size: .78rem; color: #8593ab; }
h3 { font-size: 1rem; color: #16202e; margin-bottom: 12px; }
/* ---- 1. spinner: one element, one border trick ---- */
.spinner {
width: 28px; height: 28px;
border: 3px solid #e2e7ef;
border-top-color: #3b4fe4;
border-radius: 50%;
animation: spin .7s linear infinite;
}
/* ---- 2. dots ---- */
.dots { display: flex; gap: 6px; height: 28px; align-items: center; }
.dots i { width: 8px; height: 8px; border-radius: 50%; background: #3b4fe4; animation: bounce 1.1s ease-in-out infinite; }
.dots i:nth-child(2) { animation-delay: .15s; }
.dots i:nth-child(3) { animation-delay: .3s; }
/* ---- 3. indeterminate bar ---- */
.bar { width: 100%; height: 5px; border-radius: 5px; background: #e2e7ef; overflow: hidden; position: relative; }
.bar::after {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 40%;
border-radius: 5px;
background: #3b4fe4;
animation: slide 1.3s ease-in-out infinite;
}
/* ---- 4. pulse ---- */
.pulse { width: 28px; height: 28px; border-radius: 50%; background: #3b4fe4; animation: pulse 1.2s ease-out infinite; }
/* ---- 5. progress ring, driven by a custom property ---- */
.ring {
--p: 68;
width: 34px; height: 34px;
border-radius: 50%;
background: conic-gradient(#3b4fe4 calc(var(--p) * 1%), #e2e7ef 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 0);
}
/* ---- 6. bars ---- */
.bars { display: flex; gap: 4px; align-items: flex-end; height: 28px; }
.bars i { width: 5px; height: 100%; border-radius: 3px; background: #3b4fe4; animation: stretch 1s ease-in-out infinite; }
.bars i:nth-child(2) { animation-delay: .1s; }
.bars i:nth-child(3) { animation-delay: .2s; }
.bars i:nth-child(4) { animation-delay: .3s; }
/* ---- skeleton ---- */
.skeleton { max-width: 420px; }
.sk { border-radius: 7px; background: #eef1f6; position: relative; overflow: hidden; }
.sk + .sk { margin-top: 10px; }
.sk-img { height: 120px; }
.sk-line { height: 12px; }
.w70 { width: 70%; } .w90 { width: 90%; } .w50 { width: 50%; }
.sk::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255,255,255,.75), transparent);
animation: shimmer 1.4s infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: .45; } 50% { transform: translateY(-7px); opacity: 1; } }
@keyframes slide { 0% { left: -40%; } 100% { left: 100%; } }
@keyframes pulse { 0% { transform: scale(.6); opacity: 1; } 100% { transform: scale(1.25); opacity: 0; } }
@keyframes stretch { 0%, 100% { height: 40%; } 50% { height: 100%; } }
@keyframes shimmer { 100% { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
.spinner, .dots i, .bar::after, .pulse, .bars i, .sk::after { animation: none; }
.pulse { opacity: .6; }
.bar::after { left: 0; width: 100%; opacity: .5; }
.sk::after { display: none; }
}How it works
The skeleton is usually the right answer. A spinner says "something is happening". A skeleton says "here is the shape of what is coming", which makes the wait feel shorter and stops the page jumping when the content lands. Use skeletons for content you are fetching, spinners for actions you have triggered.
The spinner is one element. A border on all four sides, a different colour on one of them, and a rotation. No SVG, no images, no extra markup.
The progress ring uses conic-gradient and a mask. The gradient draws a filled pie; the radial mask punches out the middle to leave a ring. The fill is one custom property, so style="--p: 68" from your own code is all it takes to drive it — no SVG stroke-dasharray arithmetic.
Stagger with animation-delay, not separate keyframes. The dots and bars share one animation and differ only by delay. Three lines instead of three animations.
The shimmer moves a gradient, not the element. transform: translateX on a pseudo-element runs on the compositor. Animating background-position instead — the common version of this effect — forces a repaint on every frame.
Reduced motion is not "no feedback". Under prefers-reduced-motion the animations stop but the indicators remain visible, with the bar shown as a static filled track. Removing the indicator entirely would leave those users with no sign that anything is loading at all.
Accessibility notes
A loading indicator with no text is invisible to a screen reader. The skeleton block here carries aria-busy="true", aria-live="polite" and an aria-label, so the state is announced once and again when it changes.
Do not use aria-live="assertive" for loading. It interrupts whatever is being read. polite waits for a pause, which is what you want for a status update.
Avoid anything that flashes more than three times a second — that threshold is a seizure risk and it is in WCAG for a reason. None of these cross it, but fast custom animations easily can.
Making it yours
Size everything from the element's own width and height. All six scale correctly because the internals are proportional or in em-like ratios.
For a determinate bar, drop the animation and set the width from your own progress value. Add role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax so the figure is announced.
Related templates
Button set
Primary, secondary, ghost and danger, in three sizes, with every state covered.
HTMLCSSTailwindModal dialog
A modal built on the native dialog element — focus trap and backdrop included, for free.
HTMLCSSJavaScriptAccordion and FAQ
Built on details and summary — open, close and keyboard support with no JavaScript.
HTMLCSSTooltips
CSS-only tooltips on four sides, plus the anchor-positioning version.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.