Skeleton

Animated loading placeholder. Mirrors the shape of the content that's coming — rectangles for images and headings, circles for avatars, stacked text lines for paragraphs — so the layout doesn't jump when real data arrives. Built-in shimmer animation, alternative pulse mode, and automatic respect for prefers-reduced-motion.

Usage

import { Skeleton } from '@bwo-ui/react';

<Skeleton width={200} height={20} />
<Skeleton variant="circle" width={40} height={40} />
<Skeleton variant="text" lines={3} />

Variants

Three semantic shapes covering the common loading targets:

variant=rect
variant=circle
variant=text
<Skeleton width={140} height={56} />              {/* rect (default) */}
<Skeleton variant="circle" width={56} height={56} />
<Skeleton variant="text" lines={3} />

Animation modes

Three animation modes — shimmer (default — a soft gradient sweeping left to right), pulse (opacity fade in / out), and none (static placeholder). Use none deliberately if you want a quiet loading state, or when an external animation is already carrying the loading feedback.

shimmer
pulse
none
<Skeleton animation="shimmer" height={14} />  {/* default */}
<Skeleton animation="pulse"   height={14} />
<Skeleton animation="none"    height={14} />

Skeletons automatically disable their animation when the OS reports prefers-reduced-motion: reduce — you don't need to set animation="none" manually for that case.

Text lines

Pass lines (with variant="text") to render a stacked paragraph placeholder. The component wraps the lines in a flex column with an 8 px gap and shortens the final line to ~70 % width — the visual cue that mimics how natural prose wraps.

lines=1 (single)
lines=3
lines=5
<Skeleton variant="text" />            {/* one line */}
<Skeleton variant="text" lines={3} />  {/* three stacked lines */}
<Skeleton variant="text" lines={5} />  {/* five stacked lines */}

Sizing

width and height accept numbers (treated as pixels) or any CSS string ("100%", "14ch", "clamp(…)"). For custom corner shapes, pass radius as a CSS value — useful for matching the radius of the real element that'll replace the skeleton (chip buttons, rounded media, etc.).

<Skeleton width={160} height={20} />
<Skeleton width="100%" height={20} />
<Skeleton width="80%" height={20} />
<Skeleton width={200} height={80} radius="16px" />
<Skeleton variant="circle" width={64} height={64} />

Recipes

List item

A typical row in a directory, mention list, or settings panel — avatar + name + sub text + an action button. The skeleton shapes mirror those slots one-to-one so the layout reads the same with or without data.

<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
  <Skeleton variant="circle" width={40} height={40} />
  <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
    <Skeleton height={12} style={{ width: '45%' }} />
    <Skeleton height={10} style={{ width: '65%' }} />
  </div>
  <Skeleton width={64} height={28} radius="14px" />
</div>

Card

Card placeholders mirror the same anatomy as a real card — media block on top, title line, a couple of body lines, and a footer-pinned action. Wrap the skeletons in a real Card so the surface treatment (border, shadow, padding) is identical between loading and loaded states.

<Card>
  <Skeleton height={120} radius="8px" style={{ marginBottom: 14 }} />
  <Skeleton height={16} style={{ width: '60%', marginBottom: 8 }} />
  <Skeleton variant="text" lines={2} />
  <CardFooter>
    <Skeleton width={88} height={32} radius="16px" />
  </CardFooter>
</Card>

Article paragraph

Long-form content (a blog post, an essay, a doc page) loads cleanest when the heading, byline, and paragraph all have skeletons. variant="text" with a high line count handles the body.

<Skeleton height={28} style={{ width: '70%' }} />  {/* heading */}
<Skeleton height={14} style={{ width: '40%' }} />  {/* byline */}
<Skeleton variant="text" lines={6} />              {/* body */}

Toggle between loading and loaded

The real production pattern: render skeletons while data is fetching, swap to real components once it arrives. Wrap the loading region in aria-busy="true" so assistive tech can announce the state change.

Loading
function TeamCard({ data, loading }) {
  return (
    <Card>
      <CardHeader>
        {loading
          ? <Skeleton height={20} style={{ width: '45%' }} />
          : <CardTitle>Team</CardTitle>}
      </CardHeader>
      <div aria-busy={loading} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        {(loading ? Array.from({ length: 3 }) : data).map((person, i) => (
          <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
            {loading
              ? <>
                  <Skeleton variant="circle" width={40} height={40} />
                  <div style={{ flex: 1 }}>
                    <Skeleton height={12} style={{ width: '45%' }} />
                    <Skeleton height={10} style={{ width: '60%' }} />
                  </div>
                </>
              : <>
                  <Avatar fallback={person.initials} />
                  <div>
                    <p>{person.name}</p>
                    <p>{person.role}</p>
                  </div>
                </>}
          </div>
        ))}
      </div>
    </Card>
  );
}

When to reach for a skeleton vs a spinner

Accessibility

Props

PropTypeDefaultDescription
variant'rect' | 'circle' | 'text''rect'`rect` for blocks, `circle` for avatars, `text` for paragraph lines (combine with `lines`).
animation'shimmer' | 'pulse' | 'none''shimmer'`shimmer` = sweeping gradient (default), `pulse` = opacity fade, `none` = static. All animations are disabled automatically under `prefers-reduced-motion: reduce`.
widthnumber | stringNumbers are treated as pixels. Strings pass straight through — `"100%"`, `"14ch"`, `"clamp(...)"`.
heightnumber | stringSame value semantics as `width`. For `text` variant, defaults to 0.9 em.
linesnumberFor `variant="text"` only — renders a stacked group of N lines. Last line is shortened to ~70 % width to read like the end of a paragraph.
radiusstringCustom CSS border-radius override (e.g. `"12px"`, `"50%"`). Useful when the skeleton needs to match the radius of a non-standard element behind it.
circlebooleanDeprecated alias for `variant="circle"`. Kept for back-compat with 0.4.x.
…restHTMLAttributes<HTMLSpanElement>All native span attributes are forwarded — `style`, `aria-*`, `className`.