Skip to content
UI

Skeleton

A pulsing placeholder in the shape of content that is still loading.

@zenoxai/ui/components/skeleton Markdown
<div className="flex w-full max-w-xs items-center gap-3">
  <Skeleton className="size-9 rounded-full" />
  <div className="flex flex-1 flex-col gap-2">
    <Skeleton className="h-3.5 w-3/4" />
    <Skeleton className="h-3 w-1/2" />
  </div>
</div>

Usage

import { Skeleton } from "@zenoxai/ui/components/skeleton"

export function AlertTitleSkeleton() {
  return (
    <div className="flex flex-col gap-2">
      <Skeleton className="h-3.5 w-3/4" />
      <Skeleton className="h-3 w-1/2" />
    </div>
  )
}

Use skeletons on the first load of a view whose layout is known: a table, a card, a detail panel. They hold the space the content will take, so nothing jumps when the data arrives.

Skeleton is an empty div with a muted background and a pulse. It has no size of its own: set h-* and w-* to match the element it stands in for, and rounded-full for avatars and icons. Match the height of text lines to the type scale: h-3.5 for 12.5px body text, h-4 or h-5 for titles.

Use something else when:

  • The wait is short and has no fixed layout, such as a button action: use a Spinner.
  • The operation reports its progress: use a Progress bar.
  • The data is already on screen and is refreshing: keep it visible and show a spinner instead of blanking it.

Examples

Card

Mirror the structure of the real card: title, description, the main value, then the body. Keep the real Card around the skeletons so the border and padding do not change on load.

Loading exposure summary
<Card className="w-full max-w-xs" aria-busy="true">
  <span className="sr-only">Loading exposure summary</span>
  <CardHeader>
    <Skeleton className="h-4 w-32" />
    <Skeleton className="h-3 w-48" />
  </CardHeader>
  <CardContent className="flex flex-col gap-3">
    <Skeleton className="h-8 w-20" />
    <Skeleton className="h-24 w-full" />
    <div className="flex gap-2">
      <Skeleton className="h-5 w-16" />
      <Skeleton className="h-5 w-16" />
    </div>
  </CardContent>
</Card>

Table rows

Keep the real header and render skeleton cells in a few rows, sized like the column content. Five rows are enough to read as a table; do not fill the viewport.

Loading alerts
AlertSeverityAssetFirst seen
<div className="w-full max-w-xl" aria-busy="true">
  <span className="sr-only">Loading alerts</span>
  <Table>
    <TableHeader>
      <TableRow>
        <TableHead>Alert</TableHead>
        <TableHead>Severity</TableHead>
        <TableHead>Asset</TableHead>
        <TableHead className="text-right">First seen</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      {[0, 1, 2, 3, 4].map((row) => (
        <TableRow key={row}>
          <TableCell>
            <Skeleton className="h-3.5 w-40" />
          </TableCell>
          <TableCell>
            <Skeleton className="h-5 w-16" />
          </TableCell>
          <TableCell>
            <Skeleton className="h-3.5 w-28" />
          </TableCell>
          <TableCell>
            <Skeleton className="ml-auto h-3.5 w-20" />
          </TableCell>
        </TableRow>
      ))}
    </TableBody>
  </Table>
</div>

Text

For a paragraph, vary the widths and make the last line shorter, like real text.

<div className="flex w-full max-w-sm flex-col gap-2">
  <Skeleton className="h-5 w-2/3" />
  <Skeleton className="h-3.5 w-full" />
  <Skeleton className="h-3.5 w-full" />
  <Skeleton className="h-3.5 w-4/5" />
</div>

Accessibility

  • Skeletons are empty, so screen readers skip them. Mark the region that is loading with aria-busy="true" and add a visually hidden label (“Loading alerts”) so the user knows something is coming. Remove both when the content renders.
  • The pulse stops for users who ask for reduced motion.

API reference

Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.

Skeleton

Skeleton is a pulsing placeholder in the shape of content that is still loading. Size it like what replaces it, so the layout does not jump. For an action in progress, use Spinner.

No props of its own. Accepts the props of the element it renders.