Skeleton
Placeholder lines, avatars and blocks shown while content loads, driven by aria-busy.
On this page
A container with aria-busy="true" shows its empty data-skeleton children as placeholders. The values are line, avatar and block. Set the width of a line with inline-size. When the data arrives, set aria-busy="false" or swap the content.
Load css/, or the whole set from css/pico.extras.min.css. See the overview.
A container that holds skeletons does not show the core aria-busy spinner. A container with aria-busy="true" and no skeletons keeps it (for example <p ).
Card
Give the skeleton the shape of the content it stands in for: one line per line of text, an avatar only where the loaded card has an image. When the two match, the swap does not move the page.
<article aria-busy="true" aria-label="Loading invoice">
<header>
<p data-skeleton="line" style="inline-size: 40%"></p>
</header>
<p data-skeleton="line"></p>
<footer>
<p data-skeleton="line" style="inline-size: 30%"></p>
</footer>
</article>The same card once the data is in, with aria-busy="false":
<article aria-busy="false">
<header>
<div style="display: flex; align-items: center; gap: var(--pico-spacing)">
<strong>Acme Cloud</strong>
<small>Invoice 2041, paid</small>
</div>
</header>
<p>Your team plan renewed on 1 October. The next invoice is due on 1 November.</p>
<footer><small>Billing contact: billing@acme.example</small></footer>
</article>Text lines
Use a role="status" container with an aria-label so screen readers announce the loading state and the finished content.
<section role="status" aria-busy="true" aria-label="Loading comments">
<p data-skeleton="line"></p>
<p data-skeleton="line" style="inline-size: 90%"></p>
<p data-skeleton="line" style="inline-size: 70%"></p>
</section>Avatar and lines
<div
role="status"
aria-busy="true"
aria-label="Loading profile"
style="display: flex; align-items: center; gap: var(--pico-spacing)"
>
<span data-skeleton="avatar"></span>
<div style="flex: 1">
<p data-skeleton="line" style="inline-size: 50%"></p>
<p data-skeleton="line" style="inline-size: 30%"></p>
</div>
</div>Results swapped by htmx
A results list swapped by htmx follows the same pattern. The list starts busy with three lines, and htmx replaces the inner HTML when the response arrives. The new content has no skeletons, so set aria-busy="false" on the response, or let your code clear the attribute after the swap.
<section
id="results"
aria-busy="true"
aria-label="Search results"
hx-get="/customers"
hx-trigger="keyup changed delay:300ms from:input[name=q]"
hx-target="this"
hx-swap="innerHTML"
>
<p data-skeleton="line"></p>
<p data-skeleton="line" style="inline-size: 75%"></p>
<p data-skeleton="line" style="inline-size: 55%"></p>
</section>Accessibility
aria-busy="true"tells assistive technology the region is still updating. Name the region witharia-labelso it is clear what is loading.- The placeholders are empty and have no text, so there is nothing for a screen reader to read.
- The shimmer only runs under
prefers-reduced-motion:. Everyone else gets the flat color.no-preference
Customization
--pico-skeleton-color- Base color of a placeholder.
--pico-skeleton-highlight-color- Color of the moving highlight.
--pico-skeleton-duration- Time for one sweep of the shimmer.
--pico-skeleton-line-height- Height of a line.
--pico-skeleton-avatar-size- Diameter of an avatar placeholder.
--pico-skeleton-block-size- Height of a block.