A skeleton component that doesn't match its real content causes more harm than a plain spinner, because the layout jump when real content arrives undoes whatever perceived-performance benefit the skeleton was supposed to provide. Here's a step-by-step approach to building one that actually holds its shape.
This walkthrough uses plain CSS and a framework-agnostic hook pattern, so the steps translate to React, Vue, Svelte, or a server-rendered template with a small amount of adaptation. The core ideas, layout reuse, debounce timing, and accessibility markup, don't change based on which framework is rendering the component.
Step 1: Start From the Real Component, Not a Blank Slate
Don't design a skeleton as a separate component built from scratch. Open the real component you're loading into, whatever renders a card, a list row, a profile header, and note every distinct visual element: image, title, subtitle, badge, action buttons. Your skeleton needs a placeholder shape for each one of these, in the same relative position.
Write this inventory down as an actual list before touching code. It's easy to miss a smaller element, a badge, an icon, a secondary line of metadata, when working from memory instead of the rendered component, and a missing placeholder is exactly the kind of gap that causes a visible jump when the real content fills in.
Step 2: Reuse the Same Layout Container
If your real component uses a specific grid, flexbox structure, or fixed dimensions, your skeleton component should render inside that exact same container, just swapping the content for placeholder shapes. A separate skeleton layout that approximates the real one, rather than literally reusing it, is where most of the layout-mismatch bugs creep in.
.card-skeleton {
display: grid;
grid-template-columns: 64px 1fr;
gap: 12px;
padding: 16px;
}
Matching this exactly to the real card's grid definition means the skeleton and the loaded card occupy identical space, so nothing shifts when one replaces the other. If your design system uses shared spacing tokens or a grid utility class, apply the exact same classes to the skeleton container rather than approximating the spacing with hardcoded values that can drift out of sync as the real component evolves.
Step 3: Size Placeholder Shapes to Match Real Content Dimensions
A title placeholder should be roughly the height and width of an actual title in that context, not an arbitrary gray bar. If your typical title runs 20 to 40 characters at a given font size, size the placeholder bar accordingly, varying it slightly between repeated skeleton rows so a list of skeletons doesn't look like an obviously repeated stamp.
A simple way to add that variation without hardcoding several fixed widths is to randomize the placeholder width within a sensible range on each render, so five skeleton rows in a list each look slightly different instead of visibly identical, which reads as more natural and less obviously synthetic.
Step 4: Add a Debounce Before Rendering the Skeleton
Wrap the skeleton's visibility in a short delay, typically 200 to 300 milliseconds, so fast responses skip it entirely instead of flashing it in and out.
function useDelayedLoading(isLoading, delay = 250) {
const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
if (!isLoading) {
setShowSkeleton(false);
return;
}
const timer = setTimeout(() => setShowSkeleton(true), delay);
return () => clearTimeout(timer);
}, [isLoading, delay]);
return showSkeleton;
}
This small hook, or the equivalent in whatever framework you're using, prevents the skeleton from becoming visual noise on requests that resolve almost instantly. Tune the delay value against your own typical response times rather than copying 250 milliseconds blindly, since a product with consistently fast responses might set it lower, while one with more variable latency might set it slightly higher.
Step 5: Apply a Subtle Shimmer Animation
A slow gradient sweep signals "in progress" without competing for attention. Keep the animation slow and low-contrast, and wrap it in a prefers-reduced-motion media query so users who've disabled motion at the OS level get a static placeholder instead. CSS-Tricks has several worked examples of shimmer gradients if you want a starting point beyond the minimal one below.
@media (prefers-reduced-motion: no-preference) {
.skeleton-shape {
background: linear-gradient(90deg, #eee 25%, #ddd 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
}
}
Step 6: Wire Up Accessibility Attributes
A skeleton is a purely visual construct, and without extra markup, a screen reader user gets either silence or a series of unlabeled elements. Wrap the loading region in aria-busy="true" and provide a visually hidden text label so assistive technology has something meaningful to announce while the real content loads.
<div aria-busy="true" aria-live="polite">
<span class="sr-only">Loading content</span>
<div class="card-skeleton">...</div>
</div>
MDN's documentation on aria-busy covers the full attribute behavior and browser support if you want the details beyond this minimal example. The W3C Web Accessibility Initiative also publishes broader guidance on live regions and busy states worth reading before this pattern ships across a whole component library.
Step 7: Swap States Without Losing Focus
When real content replaces the skeleton, make sure keyboard focus doesn't silently jump to the document body or get lost entirely. If the user had focused an element before the swap, either preserve that focus target or move it deliberately to a sensible landing spot, rather than letting the DOM swap strand it.
This is easy to overlook because it only shows up when testing with a keyboard rather than a mouse, so it's worth adding a specific keyboard-only pass to your test checklist for any component that swaps between a skeleton and real content.
Step 8: Handle Lists With Variable Skeleton Counts
For a list or feed, render a small, fixed number of skeleton rows, typically matching your default page size, rather than one giant skeleton block. This keeps the loading state visually consistent with what a real page of results looks like, instead of implying an unknown or unrealistic amount of incoming content.
For infinite-scroll patterns specifically, only show skeleton rows at the bottom of the already-loaded list while the next page fetches, rather than replacing the entire visible list with skeletons on every subsequent page load, which would undo the continuity the user has already built up while scrolling.
Step 9: Test on a Throttled Connection
Everything above needs validation on realistic network conditions, not just fast office wifi. Throttle to a mid-tier mobile profile and watch the full sequence: debounce delay, skeleton appearance, shimmer pacing, and the final swap to real content. Issues with timing or layout mismatch are far more obvious under throttling than on a fast connection where the whole sequence resolves in a blink. Web.dev's performance tooling guidance covers how to set up this kind of throttled test rig if you don't already have one.
Where This Fits Into a Larger Design System
Once this pattern works for one component, document the debounce timing, shimmer treatment, and accessibility markup at the system level so every new component inherits the same defaults. 137Foundry has walked teams through exactly this kind of component-library rollout, and this longer guide on skeleton loading states covers the design side of the same problem this piece approaches from the implementation angle.
Wrapping Up
A skeleton component is only worth building if it mirrors its real counterpart closely enough that the swap feels seamless. Reuse the real layout, size placeholders to real dimensions, debounce the appearance, keep the shimmer subtle, and don't skip the accessibility markup. Get those five things right and the component earns the perceived-performance benefit it's meant to deliver.
Top comments (0)