Skip to main contentSkip to docs navigation

Loading placeholder that mimics a piece of content's shape while it's still loading, to prevent layout shift.

Overview

<Skeleton> renders a loading placeholder shaped like the content it stands in for, holding a layout's size steady while data loads. It always renders whatever is passed to it — swapping a skeleton for its real content once data arrives is left to application state, not a prop on the component itself. It renders a <span> by default, and every other prop is optional: width, color, and animation are opt-in on top of the bare shape.

  • span sizes a skeleton in twelfths of its parent, at every width or per breakpoint, with the width utilities of @chassis-ui/css.
  • color overrides the default currentColor background with one of the context colors.
  • animation adds skeleton-glow or skeleton-wave alongside the base skeleton class on the element it's set on, so that element animates itself — nesting plain <Skeleton> children inside an animated one animates the whole group together.
  • component renders the skeleton as another element or component, for matching the shape of whatever it's standing in for.

A Tailwind build of @chassis-ui/css has the classes of span only with its safelist loaded: see Tailwind.

Examples

Basic usage

A single <Skeleton span={n}> mimics one line of content. Stack several inside a flowing container for a paragraph-shaped block — each one wraps like an inline word once a row fills, so the ragged trailing edge falls out naturally.

JSX
import { Skeleton } from '@chassis-ui/react'

export const Example = () => {
  return (
    <div className="vstack gap-md" aria-hidden="true">
      <Skeleton span={12} />
      <p>
        <Skeleton span={7} />
        <Skeleton span={4} />
        <Skeleton span={4} />
        <Skeleton span={6} />
        <Skeleton span={8} />
      </p>
    </div>
  )
}

aria-hidden="true" only hides a skeleton from assistive technology — it doesn't manage the loading state itself. Toggle between the skeleton and its real content with application code, and pair the swap with an aria-live announcement (or a live region already present in the real content) so assistive technology users learn when the data has finished loading.

Width

Set span to a width in twelfths of the parent: span={6} is half (.w-6/12) and span={12} the full width (.w-100). span="auto" is the natural width of the content, and span alone fills the rest of a flex row. An object changes the width from a breakpoint up, with the same values: span={{ base: 12, md: 6 }}. Reach for className or an inline style for any other width.

JSX
import { Skeleton } from '@chassis-ui/react'

export const Example = () => {
  return (
    <>
      <Skeleton span={6} />
      <Skeleton className="w-75" />
      <Skeleton style={{ width: '30%' }} />
    </>
  )
}

Color

A skeleton's background is currentColor by default. Set color to any context color to match a branded or role-specific loading state.

JSX
import { Skeleton } from '@chassis-ui/react'

export const Example = () => {
  return (
    <>
      <Skeleton span={12} />
      <Skeleton color="default" span={12} />
      <Skeleton color="alternate" span={12} />
      <Skeleton color="primary" span={12} />
      <Skeleton color="secondary" span={12} />
      <Skeleton color="neutral" span={12} />
      <Skeleton color="success" span={12} />
      <Skeleton color="danger" span={12} />
      <Skeleton color="warning" span={12} />
      <Skeleton color="info" span={12} />
    </>
  )
}

Animation

Set animation on a wrapping <Skeleton component="p"> (or any other element) to convey that content is actively loading rather than merely present. The animated element gets skeleton-glow/skeleton-wave alongside its own skeleton class, so it animates itself too, and plain <Skeleton> children nested inside it inherit the effect.

JSX
import { Skeleton } from '@chassis-ui/react'

export const Example = () => {
  return (
    <>
      <Skeleton component="p" animation="glow" className="w-100">
        <Skeleton span={12} />
      </Skeleton>
      <Skeleton component="p" animation="wave" className="w-100">
        <Skeleton span={12} />
      </Skeleton>
    </>
  )
}

Custom elements

<Skeleton> is polymorphic: pass component to render it as another element or component instead of the default <span>, and the props checked at the call site follow whatever component is — <Skeleton component={Button} variant="outline"> type-checks variant against Button's own props, not Skeleton's. Skeleton's own background/opacity/animation styling wins over the target's, while the target's own CSS still supplies the shape (button padding, avatar border-radius, etc.), so the result is a correctly-shaped, correctly-animated stand-in with no extra work. Pass the target's own disabled prop, as shown below for both Button and Avatar, to hold its height steady and get its existing disabled styling for free — no manual .disabled class or aria-disabled needed.

JSX
import { Avatar, Button, Skeleton } from '@chassis-ui/react'

export const Example = () => {
  return (
    <div className="d-flex align-items-center gap-md">
      <Skeleton component={Avatar} size="lg" aria-hidden="true" />
      <Skeleton component={Button} span={3} disabled aria-label="Loading" />
      <Skeleton component={Button} variant="outline" span={3} disabled aria-label="Loading" />
    </div>
  )
}

chassis-css applies extra styling to .button elements via ::before so a button's height holds steady once a skeleton replaces its label. Extend the same ::before pattern for other components, or add a non-breaking space inside an element to preserve its height once real content renders in place of the skeleton.

Don't reach for <Placeholder> as component here despite the name — it solves a different problem. <Placeholder> is a permanent stand-in for image content that's genuinely missing, not a temporary loading state, and it defaults to a visible title/text label with role="img"/aria-label, which would nest a screen-reader-announced element inside what's meant to be a decorative skeleton. Size a bare <Skeleton> to match the real image instead — see Loading states for a worked example swapping a <CardImage> for one while loading.

Swapping in real content

<Skeleton> never manages the swap to real content itself — every example above still needs application code deciding when to render the real thing instead. <SkeletonLoader> does exactly that swap: pass the eventual content as children, and it renders a matching skeleton while loading is true, or children unchanged — no wrapper element at all — once it's false.

For a run of text, spans sizes one skeleton line per entry (a bare value like spans={6} is shorthand for a single line, equivalent to spans={[6]}).

JSX
import { useState } from 'react'
import { Button, SkeletonLoader } from '@chassis-ui/react'

export const Example = () => {
  const [loading, setLoading] = useState(true)

  return (
    <div className="vstack gap-md">
      <p className={loading ? 'skeleton-glow mb-0' : 'mb-0'}>
        <SkeletonLoader loading={loading} spans={[12, 9, 5]}>
          Chassis is a design system and component library built for teams who need to move fast
          without sacrificing consistency or accessibility.
        </SkeletonLoader>
      </p>
      <Button onClick={() => setLoading(!loading)}>
        {loading ? 'Mark as loaded' : 'Reset to loading'}
      </Button>
    </div>
  )
}

<SkeletonLoader> is polymorphic too, exactly like <Skeleton> — its component prop (and that component's own props, type-checked the same way) work for non-text content, not just text runs. Leave spans unset for a target that already sizes itself, like Avatar's size prop. See Loading states's profile example for <Skeleton component={Avatar}>/<Skeleton component={Button}> used through <SkeletonLoader> this way.

Unlike <Skeleton>'s own animation prop, <SkeletonLoader> has no element of its own to put skeleton-glow/skeleton-wave on. Apply it to whatever container already wraps the swap — a <p>, a <Card>, a <Table> — and it reaches every generated skeleton inside automatically, regardless of nesting depth, whether via skeleton-glow's descendant selector or skeleton-wave's mask over the container's whole subtree. See Loading states for full worked examples combining both components into a card, a profile row, and a table's loading state — and for where to put the aria-live announcement when several <SkeletonLoader>s belong to one loading operation (see the callout above): once on the shared container, not once per <SkeletonLoader>, so a form or table with many fields doesn't announce itself several times over.

Scope

No reduced-motion handling. The glow and wave animations run at a fixed rate regardless of a visitor's reduced-motion preference — chassis-css doesn't gate either behind prefers-reduced-motion today. Wrap an animated <Skeleton> in that media query, or an equivalent JavaScript check, to disable the animation where it matters.

API

Skeleton

PropTypeDefault
animation?"glow" | "wave"—
Adds skeleton-{animation} alongside the base skeleton class, so the element animates itself as well as any nested <Skeleton> children. Nest plain <Skeleton> children inside it for the glow pulse to reach them too, or apply wave to a container of one or more <Skeleton> children for a directional sweep across the whole group.
asChild?boolean—
Render the single child element in place of this component's own element, with this component's classes, props and ref merged onto it: <Button asChild><Link href="/login">Log in</Link></Button>. The alternative to component for React Server Components — an element can be passed from the server to this package's client components, a component reference (component={Link}) can't.
className?string—
A string of all className you want applied to the component.
color?ContextColor—
Sets the color of the component to one of Chassis context colors.
component?ElementType'span'
Component used for the root node. Either a string to use an HTML element or a component — e.g. Avatar or Button. Its own props are type-checked at the call site once passed here.
ref?any—
span?{ Responsive<'auto' | number | string | boolean> }—
Width of the skeleton in twelfths of its parent (span={6} is half, mapped to the w-{n}/12 class; span={12} is w-100), 'auto' for its natural width (w-auto), or true to fill the rest of a flex row (flex-fill). Unset by default, so the rendered element's own intrinsic width applies — set it explicitly (e.g. span={12}) for a full-width text line; leave it unset when component is something that sizes itself, like Avatar or Button. An object sets the width from a breakpoint up: { base: 12, md: 6 }. Any other number or string (0, 13, '50%') has no width class: it sets no width, and warns in development.

SkeletonLoader

PropTypeDefault
childrenReactNode—
The real content, rendered unchanged (no wrapping element) once loading is false.
color?ContextColor—
Sets the color of the generated skeleton lines to one of Chassis context colors.
component?ElementType'span'
Component used for the generated skeleton(s) — e.g. Avatar or Button — for swapping non-text content, not just text runs. Its own props are type-checked at the call site once passed here, same as Skeleton's own component prop.
loadingboolean—
Swaps children for one <Skeleton> per entry in spans while true.
ref?any—
spans?{ 'auto' | number | string | boolean | Array<'auto' | number | string | boolean> }—
One skeleton line per entry, using the same values as Skeleton's span prop — e.g. [12, 6] for a full-width line followed by a half-width one. A single value (e.g. 6) is shorthand for a single line, equivalent to [6]. Leave unset for a single skeleton sized by component's own intrinsic width instead — e.g. an Avatar's size prop. Rendered instead of children while loading is true; ignored once loading is false.