Skip to content

Skeleton

Placeholder geometry for resolving content, in line, block, circle and button shapes.

Feedbackstarterloadingplaceholdershimmer

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Card } from '@/components/ui/card'
import { Skeleton, SkeletonText } from '@/components/ui/skeleton'
import { DemoGrid, DemoStage } from './_kit'

export default function SkeletonDemo() {
  return (
    <DemoStage>
      <p className="sr-only" role="status">
        Loading preview
      </p>

      <DemoGrid columns={2}>
        <Card>
          <div className="flex items-center gap-3">
            <Skeleton variant="circle" />
            <div className="min-w-0 flex-1 space-y-2">
              <Skeleton width="55%" />
              <Skeleton width="35%" />
            </div>
          </div>
          <div className="mt-4">
            <SkeletonText lines={3} />
          </div>
        </Card>

        <Card>
          <Skeleton variant="block" className="mb-4" />
          <Skeleton width="70%" className="mb-2" />
          <Skeleton width="45%" />
          <div className="mt-4 flex gap-2">
            <Skeleton variant="button" />
            <Skeleton variant="button" width="6rem" />
          </div>
        </Card>
      </DemoGrid>

      <div className="rounded-lg border border-line">
        {[0, 1, 2, 3].map((row) => (
          <div
            key={row}
            className="flex items-center gap-4 border-b border-line-subtle px-4 py-3 last:border-0"
          >
            <Skeleton width="1.25rem" />
            <Skeleton width="30%" />
            <Skeleton width="20%" className="ml-auto" />
          </div>
        ))}
      </div>
    </DemoStage>
  )
}

components/demos/skeleton.tsx

import { Card } from '@/components/ui/card'
import { Skeleton, SkeletonText } from '@/components/ui/skeleton'
import { DemoGrid, DemoStage } from './_kit'

export default function SkeletonDemo() {
  return (
    <DemoStage>
      <p className="sr-only" role="status">
        Loading preview
      </p>

      <DemoGrid columns={2}>
        <Card>
          <div className="flex items-center gap-3">
            <Skeleton variant="circle" />
            <div className="min-w-0 flex-1 space-y-2">
              <Skeleton width="55%" />
              <Skeleton width="35%" />
            </div>
          </div>
          <div className="mt-4">
            <SkeletonText lines={3} />
          </div>
        </Card>

        <Card>
          <Skeleton variant="block" className="mb-4" />
          <Skeleton width="70%" className="mb-2" />
          <Skeleton width="45%" />
          <div className="mt-4 flex gap-2">
            <Skeleton variant="button" />
            <Skeleton variant="button" width="6rem" />
          </div>
        </Card>
      </DemoGrid>

      <div className="rounded-lg border border-line">
        {[0, 1, 2, 3].map((row) => (
          <div
            key={row}
            className="flex items-center gap-4 border-b border-line-subtle px-4 py-3 last:border-0"
          >
            <Skeleton width="1.25rem" />
            <Skeleton width="30%" />
            <Skeleton width="20%" className="ml-auto" />
          </div>
        ))}
      </div>
    </DemoStage>
  )
}

components/ui/skeleton.tsx

import { cn } from '@/lib/cn'

/**
 * Skeleton
 *
 * Placeholder geometry for content that is still resolving. The wrapper is
 * marked `aria-hidden` and callers are expected to expose a single polite
 * "Loading" message instead — a screen reader should never have to listen to
 * eleven grey rectangles.
 */
export interface SkeletonProps {
  variant?: 'line' | 'block' | 'circle' | 'button'
  width?: string
  height?: string
  className?: string
}

export function Skeleton({ variant = 'line', width, height, className }: SkeletonProps) {
  const shape = {
    line: 'h-3 rounded-sm',
    block: 'h-24 rounded-md',
    circle: 'size-10 rounded-full',
    button: 'h-control w-24 rounded-md',
  }[variant]

  return (
    <div
      aria-hidden="true"
      className={cn('animate-pulse-token bg-surface-sunken', shape, className)}
      style={{ width, height }}
    />
  )
}

export interface SkeletonTextProps {
  lines?: number
  className?: string
}

/** Paragraph placeholder with a deliberately short final line. */
export function SkeletonText({ lines = 3, className }: SkeletonTextProps) {
  return (
    <div className={cn('flex flex-col gap-2', className)} aria-hidden="true">
      {Array.from({ length: lines }).map((_, index) => (
        <Skeleton key={index} width={index === lines - 1 ? '60%' : '100%'} />
      ))}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Use when you know the shape of what is coming. A skeleton that does not match the final layout is worse than a spinner, because it promises something untrue.

  • Every skeleton is `aria-hidden`; expose one polite "Loading" message for the region instead.
  • SkeletonText shortens its final line, which is what makes it read as a paragraph rather than a barcode.
  • Do not animate more than one screenful — the pulse becomes noise.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Line
  • Block
  • Circle
  • Button
  • SkeletonText — paragraph with a short last line

Accessibility

Hidden from AT
A screen reader should never have to listen to eleven grey rectangles.
Reduced motion
The pulse is disabled globally under `prefers-reduced-motion`.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.