On this page
A skeleton is a temporary representation of expected content, not proof that a request is progressing. Its job is to preserve orientation while the application waits. Shimmer is optional decoration on that structure, and the interface must work just as well without it.
Design the placeholder before its animation
Match the skeleton to the content's broad geometry: image region, heading, metadata, and a few text lines. Avoid tracing every letter or pretending to know a paragraph's exact length. A few stable blocks communicate more clearly than a dense imitation of a finished page.
Use the Gradient Generator to explore a narrow highlight between two similar neutral colors. Preview those colors against the actual surface in both themes. The tool supplies a visual starting point; you still need to define the moving layer, clipping, and loading lifecycle.
A layout reservation matters more than the shimmer. Set the expected media aspect ratio and preserve the card's approximate height so content replacement does not shift neighboring controls. If results have unpredictable lengths, reserve a reasonable minimum rather than a giant fixed height that leaves empty space.
The responsive container guide is useful for matching placeholder widths to real content. Test at narrow widths and increased text size, where a real title may wrap to three lines even if a desktop mockup suggests one.
Animate one restrained highlight
Build a static neutral surface, then move a highlight across it using a pseudo-element. Animating its transform is usually preferable to repainting a large background position continuously. This remains a rendering choice to verify, not a guarantee of zero cost.
.skeleton {
position: relative;
overflow: hidden;
isolation: isolate;
background: var(--skeleton-base, #e5e7eb);
border-radius: 0.375rem;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgb(255 255 255 / 35%),
transparent
);
transform: translateX(-100%);
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.loading .skeleton::after {
animation: shimmer 1.6s linear infinite;
}
}
@keyframes shimmer {
to { transform: translateX(100%); }
}Treat 1.6 seconds as an initial design setting, not an evidence-backed optimum. Watch the complete screen rather than one isolated rectangle. Twelve bright bands sweeping independently can dominate the interface even if each looks subtle in a component preview.
Use a shared rhythm across related placeholders. Synchronization makes one loading region feel coherent, while random delays can resemble unrelated activity. If a parent overlay can cover several shapes without leaking into gaps, one moving layer may also reduce compositing overhead.
Keep highlight contrast modest. In dark mode, a white streak that works on a pale card can become a distracting flash. Define theme-specific surface and highlight tokens instead of relying on opacity alone to solve every background combination.
Model loading as real application state
A loader should correspond to an explicit request state: pending, success, empty, or error. Do not infer loading from whether an array happens to be empty. An empty successful result deserves its own explanation and next action, not an endless shimmering promise.
Avoid flashing a skeleton for operations that resolve almost immediately. A short display delay can help, but treat any threshold as a product decision. If you also enforce a minimum visible duration, keep it small and never hold usable data merely to finish a decorative loop.
On refresh, consider keeping stale content visible with a lightweight updating indicator. Replacing an entire dashboard with skeletons after every filter change destroys orientation. Initial loading and background updating are different states and should not automatically share one presentation.
Stop the animation when data arrives, an error appears, the request is cancelled, or the component leaves the active view. Timers should not resurrect loading after navigation. If requests race, associate results with their request identity so an older response cannot overwrite the newer screen.
Handle partial results intentionally. A loaded heading above a still-pending chart may be useful, but dozens of independently completing skeleton fragments can produce visual chatter. Group content by meaningful units rather than whichever network promise happens to resolve first.
Keep assistive technology out of decorative markup
Mark skeleton shapes as decorative with aria-hidden on their wrapper. Put aria-busy on the actual updating region while content is being assembled, and clear it when the region is ready. See MDN's aria-busy reference for the semantics.
Provide one concise loading message when the wait needs explanation. A separate status region can announce the transition without exposing every fake heading and text bar. Avoid placing that message inside the aria-hidden skeleton wrapper, where it would also disappear from accessibility APIs.
Never place real interactive controls inside a decorative hidden wrapper. If actions are unavailable, represent their disabled state honestly or leave them out until useful. Invisible but focusable buttons are not an acceptable consequence of a polished loading effect.
Respect reduced motion by leaving the static shapes intact and omitting the shimmer. The media-query reference explains preference detection. A pulse is still animation; swapping shimmer for pulsing does not automatically accommodate motion sensitivity.
For broader focus and announcement checks, use the accessible interfaces guide. Test with an actual screen reader because repeated status updates can become noisy even when each individual attribute appears correct.
Test the handoff, not just the loop
The crucial moment is content replacement. Remove the skeleton and display the real region without briefly stacking both at full height. If you crossfade, ensure the temporary layering does not duplicate accessible content or change the layout reservation unexpectedly.
Test slow responses, failed requests, cached responses, offline operation, and navigation away during loading. A skeleton that looks good for two seconds can become misleading after a long failure. Offer retry or an explanation instead of allowing indefinite decorative activity.
Measure layout stability with realistic content. The web.dev CLS guide explains why unexpected movement matters. Skeletons reduce shifts only if their reserved space resembles the final layout; a generic block can still be a poor placeholder for a tall image or wrapped headline.
Use rendering tools to inspect paint activity and layer count on a low-powered device. Bound the animated area, avoid large blur filters, and pause offscreen decorative work when practical. Do not assume CSS automatically makes a full-screen loading effect inexpensive.
Finally, record the entire transition at ordinary speed. Check whether users can identify the page, whether the highlight competes with persistent navigation, and whether success feels immediate. If the static version communicates the state equally well, it may be the better default.
Key takeaways
- Reserve realistic geometry before adding shimmer.
- Use one restrained highlight and theme-aware colors.
- Separate initial loading, refreshing, empty results, and failure.
- Hide decorative shapes while exposing one useful loading status.
- Make static skeletons the reduced-motion experience and verify the final handoff.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor