Skip to content
Beta · ExperimentalReact 19No peer dependencies

Skeleton

Placeholders in the shape of the content that is loading, plus an Empty state for when there is nothing to show.

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

Installation

bash
npx gbs-add-block@latest -a Skeleton -beta

The block copies the skeleton folder into your project, along with the small shared folder that every component imports. You own the code and can change it freely. There are no peer dependencies other than React.

Requirements

  • React 19 and @types/react 19
  • TypeScript target ES2022 or newer, with "jsx": "react-jsx"
  • Browsers from 2024 or newer (the styles use light-dark() and color-mix())

Import the stylesheet once, for example in your global CSS:

css
@import "../components/skeleton/styles.css";

or in your root layout / entry file:

ts
import "@/components/skeleton/styles.css";

Two states that every list and panel eventually needs: one while the data is coming, and one for when it arrives and there is nothing in it.

Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

Default

Live preview

Quick Start

tsx
import { Skeleton, Empty } from "@/components/skeleton";
import "@/components/skeleton/styles.css";
 
{loading ? (
  <Skeleton lines={3} label="Loading activity" />
) : items.length === 0 ? (
  <Empty
    title="No activity yet"
    description="Once someone uploads a file, it will show up here."
    actions={<Button onClick={invite}>Invite your team</Button>}
  />
) : (
  <ActivityList items={items} />
)}

Props Table

Skeleton

PropTypeDefaultDescription
variant"text" | "circle" | "rect"textThe shape to draw.
linesnumber1Bars, for text.
lastLineWidthnumber60Width of the last line, as a percentage.
width, heightnumber | stringA number is pixels; a string is used as given.
radiusnumber | stringCorner radius for rect.
animation"pulse" | "wave" | "none"pulseHow it moves.
labelstringAnnounced while the placeholder is up.
className, classNames, styleSlots: root, line.
refRef<HTMLDivElement>The root element.

Empty

PropTypeDescription
titleReactNodeWhat is missing, in a few words.
descriptionReactNodeWhy, and what to do about it.
iconReactNodeDecorative; hidden from assistive technology.
actionsReactNodeThe way out — usually one button.
size"sm" | "md" | "lg"Default md. sm suits a panel inside a page.
className, classNames, styleSlots: root, icon, title, description, actions.

Match the shape, not the pixel

A skeleton earns its keep when it takes the same space as what replaces it, so the page does not jump when the data lands:

tsx
<div style={{ display: "flex", gap: 12 }}>
  <Skeleton variant="circle" width={40} />
  <div style={{ flex: 1 }}>
    <Skeleton lines={2} />
  </div>
</div>

With more than one line the last is drawn short, because real text does not end flush with the margin. A single line stays full width: one short bar reads as a label rather than a sentence still loading.

What screen readers hear

The bars are hidden from assistive technology. There is nothing useful to announce about the shape of a grey box, and a dozen of them announcing themselves is worse than silence.

Say the wait once, in whichever place fits:

tsx
{/* Either here… */}
<Skeleton lines={3} label="Loading activity" />
 
{/* …or on the region, if it already says it is busy. */}
<section aria-busy={loading}>
  <Skeleton lines={3} />
</section>

Empty is a normal region, not a live one: an empty result is part of the page rather than an alert, and a search that finds nothing should not interrupt what someone is reading.

Motion

pulse fades, wave runs a sheen across the bar, none is static. All of them stop under prefers-reduced-motion — an animation that repeats forever is exactly what that setting is about.

Styling and Theming

All rules are in the CSS components layer, so utility classes passed through className / classNames override them.

CSS variables

VariableUsed for
--sk-baseThe bar itself. Reads --gbs-skeleton, then --gbs-hover.
--sk-sheenThe highlight in the wave animation.
--sk-radiusCorner radius of rect and circle.
--sk-durationOne cycle of the animation (default 1.4s).
--sk-width, --sk-heightSet by the width and height props.
--sk-fg, --sk-mutedEmpty's title and description.

Override them on .sk-root / .em-root, on :root, or through style. Each variable falls back to the shared --gbs-* of the same name, then to the DataGrid's --dg-* when that stylesheet is loaded, and finally to the built-in palette.

Data attributes

ElementAttributes
Skeleton (.sk-root)data-variant, data-animation, data-lines
Empty (.em-root)data-size

Headless Use

core/lines.ts is framework-free:

ts
import { lineWidths, toLength } from "@/components/skeleton";
 
lineWidths(3);        // ["100%", "100%", "60%"]
lineWidths(1);        // ["100%"] — one line is never shortened
toLength(24);         // "24px"
toLength("3rem");     // "3rem"

Next.js

Both are marked "use client" because they accept refs and style props, but neither holds state — render them from wherever your loading check lives.

Notes

  • Nothing animates when the tab is in the background; the browser pauses CSS animations there on its own.
  • Empty centres its content and caps the description at 46 characters per line, which is about where a paragraph stops being scannable.