9 CSS Skeleton Loading Screens, Copy-Paste Ready
A css skeleton loading screen swaps a blank page for gray shapes that mirror the layout while data loads. Nine SCSS builds below, no spinner GIFs.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Shimmer lines
- 02 Card skeleton
- 03 Avatar list
- 04 Table skeleton
- 05 Image blur-up
- 06 Responsive skeleton
- 07 Skeleton to content
- 08 Wave stagger
- 09 Auto dark mode
The order follows how a real screen actually loads. 01 through 04 use the classic shimmer sweep on shapes of growing complexity — a text line, a card, an avatar row, and a table. 05 and 06 change what triggers the swap: a blurred placeholder image, and a grid that redraws itself at its own container width. 07 through 09 handle the loading state itself — the handoff from skeleton to content, a wave instead of a flat shimmer, and colors that follow dark mode without a media query. Watch each one loop in the grid above; the code under every heading keeps only the lines that matter, and the full files sit in the zip.
01Shimmer lines
Gray bars stand in for text lines, and a soft highlight sweeps across each one on a loop, built from a single ::after pseudo-element whose gradient moves on transform instead of background-position. Each bar's sweep starts at a different point using a negative animation-delay, so the four lines never move in unison. Use it for article lists and comment threads, wherever text is about to appear line by line.
.line {
position: relative;
overflow: hidden;
background: rgba($color, .14);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($color, .45), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
}
&:nth-child(2)::after { animation-delay: -140ms; }
&:nth-child(3)::after { animation-delay: -280ms; }
}
@keyframes shimmer {
to { transform: translateX(120%); }
}
02Card skeleton
One card holds three placeholder shapes — a thumbnail, a title bar, and a description bar — sharing a single %bar placeholder selector in Sass so the shimmer rule doesn't get typed three times over. The title and description sweep on their own negative delay, the same trick as item 01, so the whole card doesn't flash at once. It fits product tiles and post cards, anywhere content ships as a repeated shape.
%bar {
position: relative;
overflow: hidden;
background: rgba($color, .16);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($stage-paper, .85), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
}
}
.thumb { @extend %bar; aspect-ratio: 2 / 1; }
.title { @extend %bar; width: 70%; }
.desc { @extend %bar; }
.title::after { animation-delay: -120ms; }
.desc::after { animation-delay: -240ms; }
03Avatar list
Instead of a shimmer per line, one .sheen element covers the whole list and sweeps across every avatar and text row in a single pass. Five rows repeat a round avatar plus two text lines, and each row's first line is set to a different width so the list doesn't read as mechanically identical. It suits chat threads and follower lists, where rows repeat but shouldn't look cloned.
.list {
position: relative;
overflow: hidden;
}
.avatar {
border-radius: $r-pill;
background: rgba($color, .22);
}
.l1 { width: 78%; background: rgba($color, .22); }
.l2 { width: 48%; background: rgba($color, .14); }
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($stage-paper, .4), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
pointer-events: none;
}
04Table skeleton
The header row keeps its real labels and font weight, while only the body cells turn into gray bars, and both rows share the same grid-template-columns so the header and skeleton columns never drift out of alignment. The shimmer lives on .body alone, clipped with overflow: hidden so the light never sweeps across the header text. It suits dashboard tables, where readers expect to see what the columns mean before the rows resolve.
.row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
}
.body {
position: relative;
overflow: hidden;
}
.cell {
height: clamp(9px, 2.6vw, 11px);
background: rgba($color, .13);
}
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($subject-blue, .45), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
pointer-events: none;
}
05Image blur-up
A blurred placeholder — filter: blur(20px) on a scaled-up layer — sits under a sharp layer that fades in and out on a three-keyframe loop, standing in for the moment a real thumbnail finishes downloading and swaps in over the low-res preview. Both layers use the same repeating diagonal gradient rather than a flat color, because a smooth gradient looks almost identical blurred or sharp and the difference wouldn't read on screen. It fits hero images and galleries, wherever a proper image takes a beat to arrive.
.placeholder, .real {
position: absolute;
inset: 0;
background-image:
repeating-linear-gradient(135deg, rgba($subject-cream, .85) 0 6px, rgba($color, .85) 6px 12px);
}
.placeholder {
filter: blur(20px);
transform: scale(1.15);
}
.real {
opacity: 0;
animation: reveal $duration linear infinite;
}
@keyframes reveal {
0% { opacity: 0; transform: scale(1.04); }
50% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.04); }
}
06Responsive skeleton
The grid's column count is set by an @container query reading the width of its own wrapping box, not the viewport, so the same file collapses from three columns to two at 260px and to one at 150px whether it sits in a full-width main column or a narrow sidebar. Container queries reached this level of support in Chrome 105, Safari 16, and Firefox 110, according to caniuse, so a fallback grid-template-columns declared before the @container blocks keeps older browsers on a fixed three-column layout instead of breaking. In the demo, the wrapper's own width is animated so the column change is visible without resizing anything yourself.
.wrap {
container-type: inline-size;
container-name: skeleton;
max-width: 420px;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.card { aspect-ratio: 1 / 1; }
@container skeleton (max-width: 260px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@container skeleton (max-width: 150px) {
.grid { grid-template-columns: 1fr; }
}
07Skeleton to content
The skeleton and the real content sit in the same grid cell, so swapping between them reads as a crossfade instead of one element vanishing and the layout jumping to fill the gap: the bars fade out while the content fades in and rises 6px into place, both keyed to the same duration so they cross paths rather than leaving a blank beat. It works as the closing move for any of the other eight, the moment real data finally lands.
.card {
display: grid;
}
.skeleton, .content {
grid-area: 1 / 1;
}
.skeleton {
animation: fade-out $duration $easing infinite;
}
@keyframes fade-out {
0%, 40% { opacity: 1; }
55%, 85% { opacity: 0; }
100% { opacity: 1; }
}
.content {
animation: fade-in $duration $easing infinite;
}
@keyframes fade-in {
0%, 40% { opacity: 0; transform: translateY(6px); }
55%, 85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(6px); }
}
08Wave stagger
Six bars shimmer one after another instead of all at once, using a Sass @for loop to generate a negative animation-delay of 60ms times the row index, so the sixth row's sweep is already most of the way through the moment the page renders. Even-numbered rows run a second, reversed keyframe, so the sweep alternates direction and reads as a wave rather than six bars staggered the same way. It's built for long lists, where a flat shimmer across dozens of rows looks static instead of active.
.bar {
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
animation: shimmer $duration $easing infinite;
}
}
@for $i from 1 through 6 {
.bar:nth-child(#{$i})::after { animation-delay: -($i - 1) * 60ms; }
}
.bar:nth-child(even)::after { animation-name: shimmer-reverse; }
@keyframes shimmer-reverse {
from { transform: translateX(120%); }
to { transform: translateX(-120%); }
}
09Auto dark mode
The whole point is one CSS function: light-dark(#f5f5f5, #1a1a1a) picks the light or dark value on its own based on the page's color-scheme, so a skeleton bar never needs its own prefers-color-scheme block to know which gray to use. Because a live color swap isn't something a rendered demo loop can show on its own, this stage fakes it by overlaying a light card and a dark card and crossfading their opacity — the real, single-element version is one declaration, not two stacked cards. Any site with a dark theme can drop it straight into the background a skeleton would already have.
.card.light {
background: $stage-paper;
animation: swap-out $duration $easing infinite;
}
.card.dark {
background: $stage-ink;
animation: swap-in $duration $easing infinite;
}
@keyframes swap-out {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes swap-in {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
Where it breaks — the traps
All nine placeholders are sized with clamp() and a fixed aspect-ratio instead of guessing at round pixel values, because the first trap is a skeleton that's the wrong size: if a card's thumbnail placeholder has a different aspect ratio than the real image, the layout shifts the instant the swap happens, which is exactly the jank a skeleton exists to prevent. MDN's guide to CSS containment covers why matching box sizes ahead of time avoids that kind of shift.
The second trap is 06's container query and 09's light-dark(): both are newer CSS than a shimmer gradient — light-dark() reached the same rough browser support as container queries only this year, according to MDN — so a skeleton built only for last year's browser can silently stop responding to its container, or fall back to one flat color in the wrong theme. Ship a plain three-column grid-template-columns before the @container rule, and a manual prefers-color-scheme block before light-dark(), so older browsers get a static skeleton instead of a broken one — and if you'd rather see all nine files at once than piece the fallbacks together yourself, the zip's password is a3rjf9rd.
The third trap is the preview above: the grid sits inside a fixed, non-scrollable iframe, so every demo runs on a two-second .is-demo loop instead of reacting to a real network request. Watch it long enough and the shimmer, the blur-up, and the crossfade all repeat on a timer — that's a stand-in for showing the motion, not the real trigger, which in production is a response finishing, not a clock.
Accessibility
Every demo respects prefers-reduced-motion, and for a skeleton the honest fallback is usually a static version of the same shape rather than turning it off outright. 01 through 04 and 08 stop the shimmer sweep and settle on a slightly raised opacity, so the bars stay visible as placeholders without moving. 05 stops cycling and shows the sharp image outright; 06 stops resizing and locks to its widest three-column layout; 07 hides the skeleton layer entirely and shows the content, since crossfading two states forever isn't the point once motion is off. 09 drops its two-card crossfade and settles on the dark card alone. See MDN's reference for the prefers-reduced-motion media feature for which browsers honor it.
@media (prefers-reduced-motion: reduce) {
.line::after, .thumb::after, .title::after, .desc::after, .sheen, .bar::after {
animation: none !important; opacity: .45; transform: translateX(0);
}
.real { animation: none !important; opacity: 1; transform: scale(1); }
.wrap { animation: none !important; width: 94%; }
}
More CSS loading effects live in the CSS category, and what this site actually is gets explained on the about page.
FAQ
Does a CSS skeleton replace JavaScript loading states entirely?
No, and it isn't meant to. The nine files here only handle the visual placeholder — the shimmer, the crossfade, the container-query grid — while your app's own logic still decides when real data has arrived and it's time to swap the classes. Pair it with whatever fetch call or Suspense boundary you're already using; the CSS just makes the wait look intentional instead of blank.
Why do skeleton bars use rgba() opacity instead of a plain gray hex color?
Because most of these stages sit on a colored or dark background, and a flat gray hex value would either vanish into the stage or clash with it depending on which demo you're looking at. Layering rgba() over the stage's own subject color keeps the placeholder legible against any background without a second set of color variables per theme. It's the same reason 09 leans on light-dark() rather than hardcoding two full palettes.
How wide should a skeleton placeholder be compared to the real content?
As close as you can measure it. The bars and cards above use clamp() with pixel and viewport-based bounds rather than round numbers, because a placeholder that's noticeably narrower or has a different aspect ratio than what replaces it makes the layout jump the moment real content lands, which defeats the reason for showing a skeleton in the first place.