A skeleton loader is a temporary outline of content that has not arrived yet. Use it when you know the shape of the incoming interface, such as a product card or table row. Keep the placeholder close to the final dimensions, announce the loading state once, and replace it with content or an actionable error.
Skeletons do not make a request faster. They communicate what is coming and can help reserve space. For a short button action, a busy label is usually clearer; for an unknown operation, a spinner with descriptive text may communicate more honestly than a fake page outline.
A working card example
Example setup: Tailwind CSS 4.1.18, compiled locally, with plain HTML and vanilla JavaScript. These small educational examples are separate from the membership library. Adapt them to your application and test your final content and browser requirements.
Complete HTML, CSS, and JavaScript
Use your compiled Tailwind stylesheet in place of /examples/tailwind.css. The example uses Tailwind CSS 4 utilities and vanilla JavaScript.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,follow"><title>Loading cards without moving the page</title><link rel="stylesheet" href="/examples/tailwind.css"><style>body{font-family:system-ui,sans-serif;background:#faf9f6;color:#252824;padding:24px}button{cursor:pointer}button:disabled{cursor:wait;opacity:.6}:focus-visible{outline:3px solid #4f46e5;outline-offset:4px}</style></head><body><button id="load" class="mb-5 rounded-lg bg-stone-900 px-4 py-2 text-white">Load example cards</button>
<p id="status" role="status" class="mb-4 text-sm text-stone-600">Ready to load.</p>
<section id="results" aria-label="Example projects" aria-busy="false" class="grid grid-cols-1 gap-4 sm:grid-cols-3"></section><script>const results = document.querySelector('#results');
const status = document.querySelector('#status');
const button = document.querySelector('#load');
const titles = ['A quiet workspace', 'A new perspective', 'Made for the everyday'];
const cards = () => titles.map((title, i) => '<article class="h-56 rounded-xl border bg-white p-4"><div class="mb-4 h-28 rounded-lg bg-orange-100"></div><h2 class="font-semibold">' + title + '</h2><p class="mt-2 text-sm text-stone-600">Project ' + (i + 1) + '</p></article>').join('');
results.innerHTML = cards();
button.addEventListener('click', async () => {
button.disabled = true;
results.setAttribute('aria-busy', 'true');
status.textContent = 'Loading three projects…';
results.innerHTML = titles.map(() => '<div aria-hidden="true" class="h-56 animate-pulse rounded-xl border bg-white p-4 motion-reduce:animate-none"><div class="mb-4 h-28 rounded-lg bg-stone-200"></div><div class="h-5 w-3/4 rounded bg-stone-200"></div><div class="mt-3 h-4 w-1/2 rounded bg-stone-100"></div></div>').join('');
// Simulated delay only. Replace with a request and handle its failure.
await new Promise(resolve => setTimeout(resolve, 1400));
results.innerHTML = cards();
results.setAttribute('aria-busy', 'false');
status.textContent = 'Three projects loaded.';
button.disabled = false;
});</script></body></html>The demo deliberately uses a simulated request so you can inspect the transition. The card shell keeps a fixed height in both states. In a real page, use dimensions that accommodate your longest title rather than copying this height blindly. Avoid inserting extra banners above the results when the request completes.
Choose the right loading treatment
| Situation | Useful treatment | Avoid |
|---|---|---|
| Known card grid | Matching card skeletons | Random line lengths that bear no relation to the final cards |
| Data table | Stable column widths and a few placeholder rows | Replacing the entire page with a spinner |
| Saving a form | Disabled submit control and a status message | Hiding the entered values |
| Unknown duration | A clear progress message; percentage only if measured | Invented progress percentages |
Make loading understandable without the animation
The decorative shapes have aria-hidden="true". A separate live status explains that projects are loading and then reports completion. The results container uses aria-busy, which is reset when the operation finishes. Keeping the status outside that busy region avoids delaying the message with the content it describes.
Do not create one live region per card. Repeated announcements make a page difficult to use. Keep keyboard focus on the initiating control unless the user explicitly requested navigation to new content. The demo follows the status-message approach described by W3C’s status-message guidance.
Adapt the shape for tables and dashboards
For a table, preserve the header and column structure. Show placeholder cells inside the existing table instead of creating a different grid that later changes width. Use a short explanatory caption or status outside the busy table. For a dashboard, load sections independently when one slow request should not block the rest of the page.
At mobile widths, test the real card count and column change. A three-column desktop skeleton followed by a one-column result makes the loading transition unnecessarily disruptive. Reuse the same responsive grid classes around both states.
Handle errors, empty results, and reduced motion
The example uses motion-reduce:animate-none to keep the shapes still when reduced motion is preferred. The content must remain understandable without pulsing. Use the same principle for any shimmer effect.
In production, wrap the request in try/catch/finally. Treat an HTTP error as an error even if fetch resolves. Clear the busy state, preserve useful content, and provide a retry control. A successful response containing no items should say “No projects yet,” not leave the skeleton running.
Before shipping
- Try a fast response, a slow response, an empty response, and a failed response.
- Check the transition with reduced motion enabled.
- Confirm that long content does not overlap the reserved space.
- Verify that loading and completion are announced without moving focus.
Continue with success notifications, or explore feature layouts that need a consistent loading state.
