Components
Skeleton
A pulsing placeholder for content that hasn't loaded yet. Carries role="status" + aria-busy="true" so assistive tech announces "Loading …" while the user waits. Replaced wholesale when the real content arrives.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/skeleton.json2. Use it
import { Skeleton } from "@/components/ui/skeleton"
<Skeleton class="h-4 w-64" ariaLabel="Loading user name" />
<Skeleton class="h-4 w-48" ariaLabel="Loading user email" />Or copy the source manually
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"
// Skeleton — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Visual loading placeholder. Renders as a styled div with a subtle
// pulse animation. Pair with htmx so the real content swaps in when
// the server responds — the placeholder DOM is replaced wholesale.
//
// Accessibility:
// - role="status" + aria-busy="true" so AT announces "Loading".
// - aria-label gives the announcement substance ("Loading user list").
// - aria-labelledby points the name at an existing visible label
// (e.g. a section heading) instead of duplicating the string. Per the
// status role spec: "If a name is visible, reference it using
// aria-labelledby." When supplied it supersedes the default label.
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md
// - Once the real content swaps in, the role/aria-busy goes with it
// — no manual cleanup needed.
type SkeletonProps = {
// Required so AT users hear something meaningful while content loads.
ariaLabel?: string
// Reference a visible label (e.g. a heading) to name the status region.
ariaLabelledby?: string
class?: ClassValue
id?: string
// Pass-through for tests / debugging.
[key: `data-${string}`]: any
}
export function Skeleton(props: SkeletonProps) {
const { ariaLabel = "Loading", ariaLabelledby, class: className, id, ...rest } = props
return (
<div
id={id}
role="status"
aria-busy="true"
// A referenced visible label supersedes the hardcoded "Loading" string.
aria-label={ariaLabelledby ? undefined : ariaLabel}
aria-labelledby={ariaLabelledby}
data-slot="skeleton"
class={cn(
"animate-pulse rounded-md bg-muted",
className,
)}
{...rest}
/>
)
}
1. Save the file
Copy skeleton.html into templates/components/.
2. Use it
{% from "components/skeleton.html" import skeleton %}
{{ skeleton(extra_class="h-4 w-64", aria_label="Loading user name") }}
{{ skeleton(extra_class="h-4 w-48", aria_label="Loading user email") }}View source
{# Skeleton macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/skeleton.tsx. #}
{# aria_labelledby references a visible label (e.g. heading) and, per the
status role spec, supersedes the hardcoded "Loading" aria-label.
repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md #}
{% macro skeleton(aria_label="Loading", aria_labelledby=none, id=none, extra_class="") %}
<div
{%- if id %} id="{{ id }}"{% endif %}
role="status" aria-busy="true"
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% else %} aria-label="{{ aria_label }}"{% endif %}
data-slot="skeleton"
class="animate-pulse rounded-md bg-muted {{ extra_class }}"></div>
{% endmacro %}
1. Save the file
Add skeleton.tmpl alongside button.tmpl.
2. Use it
{{template "skeleton" (dict "Class" "h-4 w-64" "AriaLabel" "Loading user name")}}View source
{{/* Skeleton template — shadcn-htmx, htmx v4 + Tailwind v4. */}}
{{/* .AriaLabelledby references a visible label (e.g. heading) and, per the
status role spec, supersedes the hardcoded "Loading" aria-label.
repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md */}}
{{define "skeleton"}}
<div {{if .ID}}id="{{.ID}}"{{end}}
role="status" aria-busy="true"
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{else}}aria-label="{{or .AriaLabel "Loading"}}"{{end}}
data-slot="skeleton"
class="animate-pulse rounded-md bg-muted {{.Class}}"></div>
{{end}}
1. Save the file
Drop skeleton.ex into lib/my_app_web/components/.
2. Use it
<.skeleton class="h-4 w-64" aria-label="Loading user name" />View source
defmodule ShadcnHtmx.Components.Skeleton do
@moduledoc """
Skeleton — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Visual loading placeholder with role="status" + aria-busy + pulse
animation. Pair with htmx; once content swaps in, the skeleton DOM
is replaced wholesale.
## Examples
<.skeleton class="h-4 w-64" aria-label="Loading row" />
"""
use Phoenix.Component
attr :"aria-label", :string, default: "Loading"
# aria-labelledby references a visible label (e.g. heading) and, per the
# status role spec, supersedes the hardcoded "Loading" aria-label.
# repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md
attr :"aria-labelledby", :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
def skeleton(assigns) do
~H"""
<div
role="status"
aria-busy="true"
aria-label={!assigns[:"aria-labelledby"] && assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
data-slot="skeleton"
class={["animate-pulse rounded-md bg-muted", @class]}
{@rest}
/>
"""
end
end
1. Save the file
Tailwind utilities only; pulse animation is built-in.
2. Use it
<div role="status" aria-busy="true" aria-label="Loading user name"
class="animate-pulse rounded-md bg-muted h-4 w-64"></div>View source
<!--
shadcn-htmx — raw HTML skeleton snippet.
Loading placeholder with role="status" + aria-busy + pulse animation.
Naming: use aria-label for a standalone string. If a visible label
already exists (e.g. a heading), reference it with aria-labelledby
instead — it supersedes aria-label. Per the status role spec:
"If a name is visible, reference it using aria-labelledby."
repos/mdn/files/en-us/web/accessibility/aria/reference/roles/status_role/index.md
<h2 id="activity-heading">Recent activity</h2>
<div role="status" aria-busy="true" aria-labelledby="activity-heading"
data-slot="skeleton"
class="animate-pulse rounded-md bg-muted h-4 w-64"></div>
-->
<div role="status" aria-busy="true" aria-label="Loading user profile"
data-slot="skeleton"
class="animate-pulse rounded-md bg-muted h-4 w-64"></div>
Examples
Row — text placeholder
Match the rough shape of the eventual content (one or two pulsing bars at the line height you expect).
Each Skeleton needs an ariaLabel describing what's loading. Don't ship anonymous "Loading" skeletons — when several are on the page they all announce the same generic word and confuse AT users.
<Skeleton class="h-4 w-64" ariaLabel="Loading user name" />
<Skeleton class="h-4 w-48" ariaLabel="Loading user email" />{{ skeleton(extra_class="h-4 w-64", aria_label="Loading user name") }}
{{ skeleton(extra_class="h-4 w-48", aria_label="Loading user email") }}{{template "skeleton" (dict "Class" "h-4 w-64" "AriaLabel" "Loading user name")}}
{{template "skeleton" (dict "Class" "h-4 w-48" "AriaLabel" "Loading user email")}}<.skeleton class="h-4 w-64" aria-label="Loading user name" />
<.skeleton class="h-4 w-48" aria-label="Loading user email" /><div class="grid w-full max-w-md gap-2">
<div role="status" aria-busy="true" aria-label="Loading user name" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-4 w-64" data-test="row-1">
</div>
<div role="status" aria-busy="true" aria-label="Loading user email" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-4 w-48">
</div>
</div>Further reading
Card — composed skeleton
Compose skeletons to approximate the real card's silhouette — avatar circle, title bar, paragraph stripes.
The closer the skeleton matches the final layout, the less the page reflows when content arrives. Match the real card's gap, padding and rounding so the swap is visually quiet.
<Card>
<CardHeader>
<div class="flex items-center gap-3">
<Skeleton class="size-10 rounded-full" ariaLabel="Loading avatar" />
<div class="grid gap-2">
<Skeleton class="h-4 w-40" ariaLabel="Loading name" />
<Skeleton class="h-3 w-24" ariaLabel="Loading handle" />
</div>
</div>
</CardHeader>
<CardContent class="space-y-2">
<Skeleton class="h-3 w-full" ariaLabel="Loading bio line 1" />
<Skeleton class="h-3 w-5/6" ariaLabel="Loading bio line 2" />
<Skeleton class="h-3 w-4/6" ariaLabel="Loading bio line 3" />
</CardContent>
</Card>{# Compose the card placeholder by stacking skeleton macros #}{{/* Compose by nesting skeleton templates inside card */}}<.card>
<:header>
<.skeleton class="size-10 rounded-full" aria-label="Loading avatar" />
</:header>
…
</.card><div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm w-full max-w-md">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div class="flex items-center gap-3">
<div role="status" aria-busy="true" aria-label="Loading avatar" data-slot="skeleton" class="animate-pulse rounded-md bg-muted size-10 rounded-full">
</div>
<div class="grid gap-2">
<div role="status" aria-busy="true" aria-label="Loading name" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-4 w-40">
</div>
<div role="status" aria-busy="true" aria-label="Loading handle" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-24">
</div>
</div>
</div>
</div>
<div data-slot="card-content" class="px-6 space-y-2">
<div role="status" aria-busy="true" aria-label="Loading bio line 1" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-full">
</div>
<div role="status" aria-busy="true" aria-label="Loading bio line 2" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-5/6">
</div>
<div role="status" aria-busy="true" aria-label="Loading bio line 3" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-4/6">
</div>
</div>
</div>Further reading
htmx — placeholder swaps in real content
The wrapper has hx-get; the response replaces it. Skeletons render until the server responds (you can simulate latency with hx-trigger delay).
This is the htmx flash pattern flipped — instead of appending, the swap replaces the host. Hit "Refresh" to see the skeleton phase again. In production, pair with hx-indicator if you want skeleton + spinner overlays.
<div hx-get="/api/profile" hx-trigger="load" hx-swap="outerHTML">
<Card>
<CardHeader>
…skeletons matching the real layout…
</CardHeader>
<CardContent>
<Skeleton class="h-3 w-full" />
</CardContent>
</Card>
</div>
// Server returns the real <Card>…</Card> when ready.<div hx-get="/api/profile" hx-trigger="load" hx-swap="outerHTML">
{{ card_open() }} … {{ skeleton(extra_class="h-3 w-full") }} … {{ card_close() }}
</div><div hx-get="/api/profile" hx-trigger="load" hx-swap="outerHTML">
…skeletons matching the real layout…
</div><div hx-get={~p"/api/profile"} hx-trigger="load" hx-swap="outerHTML">
<.card>
…<.skeleton class="h-3 w-full" />…
</.card>
</div><div hx-get="/skeleton/profile?delay=900" hx-trigger="load" hx-swap="outerHTML" class="w-full max-w-md">
<div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div class="flex items-center gap-3">
<div role="status" aria-busy="true" aria-label="Loading avatar" data-slot="skeleton" class="animate-pulse rounded-md bg-muted size-10 rounded-full">
</div>
<div class="grid gap-2">
<div role="status" aria-busy="true" aria-label="Loading name" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-4 w-40">
</div>
<div role="status" aria-busy="true" aria-label="Loading handle" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-24">
</div>
</div>
</div>
</div>
<div data-slot="card-content" class="px-6 space-y-2">
<div role="status" aria-busy="true" aria-label="Loading bio 1" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-full">
</div>
<div role="status" aria-busy="true" aria-label="Loading bio 2" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-5/6">
</div>
</div>
</div>
</div>Further reading
API Reference
<Skeleton>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabelledby | string | — | References the id of a visible label (e.g. a section heading) to name the status region. Per the status role spec, prefer this over aria-label when a name is already visible on the page. When provided it supersedes the default "Loading" aria-label, so the two are never emitted together. |
ariaLabel | string | "Loading" | Announced by AT while content loads. Pass a specific label ("Loading user profile") so multiple skeletons aren't all just "Loading". |
class | string | — | Extra Tailwind classes appended to the root element. |