CSS Skeleton Loader Without Layout Shift

- A skeleton should reserve the content's real shape
- Complete HTML for loading and loaded states
- Complete CSS
- Why this reduces layout shift
- Do not copy the text too literally
- Make skeletons invisible to the accessibility tree
- Keep the shimmer restrained
- Responsive skeleton layouts
- Skeleton, spinner, or progress bar?
- Common skeleton mistakes
- Verify the swap with real data
A skeleton should reserve the content's real shape
A CSS skeleton loader is a temporary visual stand-in whose boxes match the dimensions of incoming content. Build the stand-in and final component on the same grid, reserve media with aspect-ratio, give text regions predictable minimum heights, hide decorative skeleton shapes from assistive technology, and expose a separate loading status. Animate a pseudo-element with transform for optional shimmer, then disable it for reduced motion.
The pattern below creates a card whose loading and loaded states occupy the same basic footprint.
Complete HTML for loading and loaded states
Show only one card state at a time. The first is the loading state:
<article class="card card--loading" aria-busy="true">
<div class="skeleton card__media" aria-hidden="true"></div>
<div class="card__body" aria-hidden="true">
<div class="skeleton skeleton--eyebrow"></div>
<div class="skeleton skeleton--title"></div>
<div class="skeleton skeleton--line"></div>
<div class="skeleton skeleton--line skeleton--line-short"></div>
</div>
<p class="visually-hidden" role="status" aria-atomic="true">
Loading article preview…
</p>
</article>
When data arrives, replace it with the final content and clear the busy state:
<article class="card" aria-busy="false">
<img
class="card__media"
src="garden-path.jpg"
alt="Curving gravel path through a green garden"
width="800"
height="450"
>
<div class="card__body">
<p class="card__eyebrow">Garden Design</p>
<h2 class="card__title">How to Plan a Garden Path</h2>
<p class="card__summary">
Choose a useful route, comfortable width, and surface that suits the site.
</p>
</div>
</article>
In a real application, preserve the live-region container outside the replaced subtree if you need it to announce both loading and completion reliably. The example keeps the state markup readable; your framework should update DOM and focus according to the interaction.
Complete CSS
.card {
--card-radius: 0.75rem;
--skeleton-base: #e2e8f0;
--skeleton-highlight: rgba(255, 255, 255, 0.65);
display: grid;
grid-template-rows: auto 1fr;
width: min(100%, 22rem);
overflow: hidden;
border: 1px solid #cbd5e1;
border-radius: var(--card-radius);
background: #ffffff;
color: #0f172a;
font-family: system-ui, sans-serif;
}
.card__media {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--skeleton-base);
}
.card__body {
min-height: 10rem;
padding: 1rem;
}
.card__eyebrow {
margin: 0 0 0.5rem;
color: #475569;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.card__title {
min-height: 2.5em;
margin: 0 0 0.75rem;
font-size: 1.25rem;
line-height: 1.25;
}
.card__summary {
margin: 0;
color: #334155;
line-height: 1.5;
}
.skeleton {
position: relative;
overflow: hidden;
border-radius: 0.375rem;
background: var(--skeleton-base);
}
.skeleton::after {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
var(--skeleton-highlight),
transparent
);
content: "";
transform: translateX(-100%);
animation: skeleton-shimmer 1.5s ease-in-out infinite;
}
.skeleton--eyebrow {
width: 35%;
height: 0.75rem;
margin-bottom: 0.75rem;
}
.skeleton--title {
width: 88%;
height: 1.25rem;
margin-bottom: 1rem;
}
.skeleton--line {
width: 100%;
height: 0.875rem;
margin-bottom: 0.625rem;
}
.skeleton--line-short {
width: 68%;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@keyframes skeleton-shimmer {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after {
animation: none;
transform: none;
}
}
The loading card uses the same .card, .card__media, and .card__body geometry as the final card. The skeleton utility adds the stand-in fill and optional highlight.
Why this reduces layout shift
Layout shift occurs when visible content moves because the browser learns a missing size or the replacement content occupies different space. A skeleton helps only if it reserves dimensions close to the final component.
The image has three safeguards:
- Both states use
.card__media. - CSS reserves a
16 / 9aspect ratio. - The final
<img>includes intrinsicwidth="800"andheight="450", which express the same ratio because800 ÷ 450 = 16 ÷ 9.
The body uses the same padding and a min-height of 10rem in both states. The title reserves at least 2.5em, which is two lines at line-height: 1.25: 2 × 1.25em = 2.5em.
Those choices do not guarantee zero shift. A three-line title, translated text, larger user font settings, or different final controls may require more space. Design skeletons from actual component variants, not from the shortest English card in the mockup.
Do not copy the text too literally
A skeleton should suggest structure without pretending to know the exact incoming sentence. Use a few varied bars, not one rectangle for every line of eventual copy. Dense shimmering paragraphs add visual noise and can make a wait feel longer.
Match stable component regions:
- Media ratio
- Title area
- Summary area
- Avatar or icon footprint
- Fixed controls that will actually appear
Do not skeletonize interface elements that are already available. If a card's menu can function before its content loads, consider keeping the real control rather than replacing it with inert gray confetti.
Make skeletons invisible to the accessibility tree
The stand-in shapes contain no content, so the example hides them with aria-hidden="true". The article carries aria-busy="true", signaling that the region is being updated, and a separate role="status" message announces the wait.
When content arrives:
- Replace the stand-in with semantic content.
- Set
aria-busy="false"or remove the attribute. - Update a persistent status region with a completion message only if that announcement helps.
- Keep keyboard focus where the interaction requires; do not move it merely because data arrived.
Do not put fake headings, empty buttons, or meaningless image alt text inside a skeleton. Assistive technology should encounter the status, then the real content—not tour a museum of unnamed rectangles.
The full accessible loading indicators guide explains the differences between status, busy, and progress semantics.
Keep the shimmer restrained
The shimmer uses an absolutely positioned pseudo-element, so it does not affect layout. Its transform moves the highlight across the already-painted base. MDN's performance guidance notes that transforms and opacity can often be handled in composition rather than triggering layout; still, many large animated skeletons can consume CPU or GPU time.
Consider these limits:
- Animate only skeletons currently visible.
- Stop and remove them when the request completes or fails.
- Avoid stacking several animated gradients on each stand-in.
- Test a full realistic list on lower-powered devices.
- Prefer a static stand-in when motion adds no useful reassurance.
The reduced-motion rule stops shimmer and leaves the base shapes visible. This preserves layout and state without perpetual movement. A static skeleton is still a skeleton; it has not lost its professional license.
Responsive skeleton layouts
If the final component changes at a breakpoint, change the skeleton through the same layout rules. For example:
@media (min-width: 48rem) {
.card--horizontal {
grid-template-columns: 10rem 1fr;
grid-template-rows: 1fr;
width: min(100%, 36rem);
}
.card--horizontal .card__media {
height: 100%;
aspect-ratio: auto;
}
}
Apply card--horizontal to both loading and final cards. The grid change then affects the stand-in and content equally. Test the loaded image's crop with object-fit: cover; geometry can match while the chosen photograph still loses its subject at a narrow crop.
Skeleton, spinner, or progress bar?
Choose feedback based on the task:
| Situation | Better pattern | Reason |
|---|---|---|
| Content card or feed is taking shape | Skeleton | Reserves expected layout |
| Small indeterminate action | CSS spinner | Compact acknowledgment |
| Inline conversational wait | Three-dot loader | Fits text rhythm |
| Known amount completed | Native or ARIA progress bar | Communicates actual progress |
| Empty result | Empty state | Nothing is loading |
| Failed request | Error and recovery action | Spinning would be false feedback |
A skeleton is most useful when the content structure is predictable and the wait is noticeable. For a button action that finishes almost immediately, swapping the whole page into gray scaffolding is theatrical overreach.
Common skeleton mistakes
The final content is taller
Test long titles, localization, large text, optional badges, and error states. Use flexible minimum sizes rather than fixed heights where content can legitimately grow.
Images still cause a jump
Give the final image intrinsic width and height attributes and a matching CSS aspect ratio. Ensure loading and final states share the same grid placement.
Screen readers encounter blank content
Hide decorative shapes, expose a meaningful status, and mark the updating region busy. Verify that real content enters the accessibility tree when loading completes.
Shimmer looks jerky
Animate transform rather than background position, reduce the number of simultaneous skeletons, and profile the real page. A static reduced-motion state should also be available.
The loader remains after an error
Replace it with an error message and a retry or alternative action. A skeleton forecasts content; after failure, continuing the forecast becomes misinformation.
The best skeleton is an honest floor plan of the component that is coming. It reserves space, reports the wait, respects motion preferences, and vanishes cleanly when the real tenant arrives.
Verify the swap with real data
Run the component with the shortest title, the longest plausible title, missing optional media, translated copy, and increased text size. Record loading and loaded dimensions in developer tools; compare the card's top, bottom, and media box before and after replacement. Also throttle the request, force an error, and confirm the stand-in becomes useful error content rather than an abandoned construction site.
Test keyboard and screen-reader behavior across the swap. No stand-in should receive focus, the busy state must clear, and newly loaded links should enter the normal tab order exactly once. Visual stability and semantic stability are separate checks, and the component needs both.