Components
Skeleton
Placeholders in the shape of content that is still loading: any shape from utilities, ready-made avatars, text, cards, lists, tables and forms.
<div class="flex items-center gap-4" role="status" aria-busy="true">
<span class="sr-only">Loading…</span>
<div class="skeleton skeleton-circle size-12" aria-hidden="true"></div>
<div class="flex flex-col gap-2" aria-hidden="true">
<div class="skeleton h-4 w-56"></div>
<div class="skeleton h-4 w-40"></div>
</div>
</div>
Markup#
.skeletonis a muted, pulsing block. Its shape is yours: size it with utilities (h-4 w-48,size-12,aspect-video), round it with.skeleton-circleor anyrounded-*.- Mark the loading region
aria-busy="true"with a text alternative (<span class="sr-only">Loading…</span>, orrole="status"and a label), and the shapesaria-hidden="true": they mean nothing read aloud. - No JavaScript: the skeleton is whatever stands in the page until htmx swaps the real content over it.
Shapes#
Ready-made shapes take the size of the thing they stand for: .skeleton-avatar (as .avatar-lg), .skeleton-button and .skeleton-input (as .btn and .input), .skeleton-badge, .skeleton-image (16:9). Utilities still resize them.
<div class="skeleton-avatar" aria-hidden="true"></div>
<div class="skeleton-avatar size-8" aria-hidden="true"></div>
<div class="skeleton-badge" aria-hidden="true"></div>
<div class="skeleton-button" aria-hidden="true"></div>
<div class="skeleton-input w-48" aria-hidden="true"></div>
<div class="skeleton size-16 rounded-xl" aria-hidden="true"></div>
<div class="skeleton-image w-40" aria-hidden="true"></div>
Text#
.skeleton-text is one line, 1em high, so it follows the font size it sits in; .skeleton-heading
is a heading-sized line at half width. Stack lines in .skeleton-lines and the last one comes out shorter, like the end of a paragraph.
<div class="text-2xl" aria-hidden="true"><div class="skeleton-heading"></div></div>
<div class="skeleton-lines text-sm" aria-hidden="true">
<div class="skeleton-text"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text"></div>
</div>
Animation#
Shapes pulse. .skeleton-shimmer, on a shape or on any ancestor, sweeps a highlight across instead; .skeleton-static stops the motion. Both stop for readers who prefer reduced motion.
<div class="skeleton-shimmer flex items-center gap-4" aria-hidden="true">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines text-sm"><div class="skeleton-text"></div><div class="skeleton-text"></div></div>
</div>
<div class="skeleton-static flex items-center gap-4" aria-hidden="true">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines text-sm"><div class="skeleton-text"></div><div class="skeleton-text"></div></div>
</div>
Avatar, card, list, table and form#
Macros in components/skeleton/skeleton.html build common placeholders from these shapes, each as a loading region (role="status", aria-busy, a hidden "Loading…"). They take label, shimmer and class.
{% from "components/skeleton/skeleton.html" import skeleton_text, skeleton_avatar, skeleton_card, skeleton_list, skeleton_table, skeleton_form %}
{{ skeleton_avatar(lines=2) }}
{{ skeleton_card(media=true, lines=2, footer=true, shimmer=true) }}
{{ skeleton_list(items=3, avatar=true) }}
{{ skeleton_table(rows=4, columns=4) }}
{{ skeleton_form(fields=2) }}
{{ skeleton_text(lines=3, label="Loading the article…") }}
With htmx#
Put the skeleton where the content will go and let htmx replace it: hx-trigger="load" fetches straight away (revealed when it scrolls into view) and hx-swap="outerHTML" swaps the placeholder for the response.
<div class="w-full max-w-sm" hx-get="/api/skeleton" hx-trigger="load" hx-swap="outerHTML">
<div class="card w-full overflow-hidden" role="status" aria-busy="true">
<span class="sr-only">Loading…</span>
<div class="card-header" aria-hidden="true">
<div class="skeleton-heading w-2/3 text-base"></div>
</div>
<div class="card-content text-sm" aria-hidden="true">
<div class="skeleton-lines"><div class="skeleton-text"></div></div>
</div>
<div class="card-footer gap-2" aria-hidden="true">
<div class="skeleton-button"></div>
<div class="skeleton-button w-20"></div>
</div>
</div>
</div>
For a reload of content already on the page, show a skeleton as the request's indicator: hx-indicator points at it, and the .htmx-indicator class keeps it hidden until a request is in flight.
Reference#
| Class | Description |
|---|---|
.skeleton | A muted block that pulses; shape it with utilities. |
.skeleton-circle | Fully rounded. |
.skeleton-text / .skeleton-heading | A line of text (1em high), a heading line (1.5em, half width). |
.skeleton-lines | A paragraph of .skeleton-text; the last line is shorter. |
.skeleton-avatar / -button / -input / -badge / -image | Shapes sized like those components. |
.skeleton-shimmer | On a shape or an ancestor: a sweeping highlight instead of the pulse. |
.skeleton-static | No animation. |
skeleton_text() … skeleton_form() | Macros: placeholders for text, avatars, cards, lists, tables and forms. |