Skip to content
Status & Labels primitive

Skeleton

Shapes that match what replaces them, so the page does not jump when data lands — and a group wrapper that announces "loading" once instead of describing six grey rectangles.

127 linesNo dependenciesAdded 13 Sept 2026
  • skeleton
  • loading
  • placeholder
  • shimmer
  • suspense

What's included

  • components/ui/skeleton.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add skeleton

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Source

One file. Create components/ui/skeleton.tsx and paste.

components/ui/skeleton.tsx
/**
 * <Skeleton> — the placeholder, and the two rules that make it work.
 *
 * Rule one: a skeleton must match the shape of what replaces it. A grey
 * rectangle where a three-line paragraph will land means the page jumps
 * when the data arrives, which is worse than a spinner — the spinner at
 * least did not lie about the layout. So this ships shapes (`text`,
 * `avatar`, `card`, `button`) rather than one box, and `lines` makes the
 * last line short, the way a real paragraph ends.
 *
 * Rule two: it is invisible to assistive technology. A screen reader user
 * does not benefit from being told there are six grey rectangles; they
 * benefit from the container saying "loading" once. So every skeleton is
 * `aria-hidden`, and `<SkeletonGroup>` is the live region that speaks.
 *
 * The animation is Tailwind's own `animate-pulse` rather than a sweeping
 * gradient, and that is a deliberate trade. A sweep looks better and needs
 * a `@keyframes` block in the consumer's stylesheet — so the component
 * would paste in, render a static grey box, and give no hint why. A
 * primitive whose appearance depends on config the buyer does not have is
 * worse than a plainer one that always works.
 *
 * Either way it stops under `prefers-reduced-motion`: an animation that
 * never ends is the exact case that setting exists for, and a flat tinted
 * block is still a perfectly good skeleton.
 */

import * as React from 'react'

type Shape = 'text' | 'avatar' | 'card' | 'button' | 'thumbnail'

export interface SkeletonProps {
  shape?: Shape
  /** For `shape="text"`: how many lines. The last one is shortened. */
  lines?: number
  /** Tailwind width class, when the default for the shape is wrong. */
  width?: string
  /** Tailwind height class, same. */
  height?: string
  className?: string
}

const BASE = 'bg-muted/70 animate-pulse motion-reduce:animate-none'

const SHAPES: Record<Shape, string> = {
  text: 'h-3.5 rounded',
  avatar: 'h-10 w-10 rounded-full',
  card: 'h-32 w-full rounded-xl',
  button: 'h-9 w-24 rounded-lg',
  thumbnail: 'h-16 w-16 rounded-lg',
}

export function Skeleton({
  shape = 'text',
  lines = 1,
  width,
  height,
  className = '',
}: SkeletonProps) {
  if (shape === 'text' && lines > 1) {
    return (
      <div aria-hidden className={`flex flex-col gap-2 ${className}`}>
        {Array.from({ length: lines }, (_, i) => (
          <div
            key={i}
            className={[
              BASE,
              SHAPES.text,
              // The last line of a paragraph is never full width, and a
              // stack of identical bars is the tell that this is a
              // placeholder rather than a preview of the shape.
              i === lines - 1 ? 'w-3/5' : 'w-full',
            ].join(' ')}
          />
        ))}
      </div>
    )
  }

  return (
    <div
      aria-hidden
      className={[BASE, SHAPES[shape], width ?? '', height ?? '', className]
        .filter(Boolean)
        .join(' ')}
    />
  )
}

export interface SkeletonGroupProps {
  /** True while loading. When false, renders `children` instead. */
  loading: boolean
  /** What a screen reader is told once, in place of the shapes. */
  label?: string
  skeleton: React.ReactNode
  children?: React.ReactNode
  className?: string
}

/**
 * The wrapper that speaks.
 *
 * `aria-busy` on a `role="status"` region is the whole accessibility story
 * for a loading screen: one announcement, once, instead of a description of
 * the placeholder furniture.
 */
export function SkeletonGroup({
  loading,
  label = 'Loading',
  skeleton,
  children,
  className = '',
}: SkeletonGroupProps) {
  return (
    <div role="status" aria-busy={loading} aria-live="polite" className={className}>
      {loading ? (
        <>
          <span className="sr-only">{label}</span>
          {skeleton}
        </>
      ) : (
        children
      )}
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
shapeShape'text'
linesFor `shape="text"`: how many lines. The last one is shortened.number1
widthTailwind width class, when the default for the shape is wrong.string—
heightTailwind height class, same.string—
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More status & labels

View all
Open the full page for this primitive

Badge

Six semantic tones across three shapes, kept as separate axes — so a red outline badge is something you can ask for rather than something the variant list forgot.

Status & Labels91 linesNo deps
Open the full page for this primitive

Status Badge

A dot and a word, with no icon-only mode by design — one in twelve men cannot separate the red state from the green one, so the word is structural rather than optional.

Status & Labels104 linesNo deps