shshadcn-htmx

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.json

2. Use it

components/ui/skeleton.tsx
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
components/ui/skeleton.tsx
/** @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

templates/components/skeleton.html
{% 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
templates/components/skeleton.html
{# 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

templates/components/skeleton.tmpl
{{template "skeleton" (dict "Class" "h-4 w-64" "AriaLabel" "Loading user name")}}
View source
templates/components/skeleton.tmpl
{{/* 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

lib/my_app_web/components/skeleton.ex
<.skeleton class="h-4 w-64" aria-label="Loading user name" />
View source
lib/my_app_web/components/skeleton.ex
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

index.html
<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
index.html
<!--
  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>

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>

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>

API Reference

<Skeleton>

PropTypeDefaultDescription
ariaLabelledbystring
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.
ariaLabelstring"Loading"
Announced by AT while content loads. Pass a specific label ("Loading user profile") so multiple skeletons aren't all just "Loading".
classstring
Extra Tailwind classes appended to the root element.