---
title: Skeleton
description: The wait, drawn as the thing being waited for: a line, a block or a circle, shimmering unless motion is reduced.
source: https://adamjurek.com/components/skeleton
---

## Home


Skeleton draws the wait as the shape of the thing being waited for: a line of
text, a block of media, a circle of an avatar. Reach for it anywhere content
has not arrived yet and a placeholder should stand in its shape until it
does.

<!-- ::start:showcase demo="skeleton" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The three shapes cover every loading state this site has actually needed, so
there is no fourth. Reduced motion is handled by removing the shimmer
outright rather than swapping it for something gentler - a static placeholder
still says "coming", and says it calmly, for someone who asked for less
motion rather than none.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/skeleton.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/skeleton.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | content · Loading |
| Files | `skeleton.tsx` |
| Dependencies | None |
| Tags | loading, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Skeleton } from "@sushindustries/ui";

export function Example() {
	return (
		<div className="flex items-center gap-3">
			<Skeleton shape="circle" />
			<Skeleton shape="line" width="60%" />
		</div>
	);
}
```

## What you should see

A soft grey shape with a light band sweeping across it every 1.4 seconds -
a circle roughly the size of an avatar, a line about a line of text tall. It
carries no text and no size unless you give it one; `aria-hidden` means a
screen reader skips straight past it, so the only way to check it worked is
to look.

## If nothing happens

Without the atoms stylesheet imported, `.skeleton` has no background, no
size and no animation - it is an empty inline `<span>` and disappears
entirely. A `block` shape with no width also collapses, because `aspect-ratio`
has nothing to size itself against inside a parent with no width of its own.


## Guides


## Composing it

Each shape assumes a different parent, because each one sizes itself
differently:

| Shape | Sizes itself | Needs from the parent |
| --- | --- | --- |
| `line` | 100% wide, 0.9em tall | a real width - a flex row or a block |
| `block` | 100% wide, 16/9 aspect ratio | a real width, or it renders at zero height |
| `circle` | fixed 40 by 40 | nothing - ignores the parent entirely |

`width` and `height` override any of these directly, on any shape.

## Variants

`shape` is the one variant, and it is a `data-shape` attribute rather than a
class, so the stylesheet - not the caller - decides what `"line"`,
`"block"` and `"circle"` look like:

```tsx
<Skeleton shape="circle" />
```

```css
.skeleton[data-shape="circle"] {
	width: 40px;
	height: 40px;
	border-radius: 999px;
}
```

## Motion and reduced motion

Under `prefers-reduced-motion: reduce` the sweep animation is removed
entirely and the shape falls back to a flat `--bg-2` fill. Nothing pulses or
fades instead - a skeleton is a placeholder, and a placeholder that keeps
moving for someone who asked for less motion is still moving.

## When not to use it

Not a loading indicator - it has no `role="status"` and announces nothing,
because it is meant to disappear the instant real content is ready, not to
tell anyone how long that will take. Reach for `Spinner` when there is an
operation in flight worth announcing, and for content already on the page
that is merely stale (a table mid-refetch, say) rather than absent.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `shape?` | `"line" \| "block" \| "circle"` | `"line"` | Shape of the thing being waited for. |
| `width?` | `string` | - | CSS size overrides; the shapes carry sensible defaults. |
| `height?` | `string` | - | Any CSS length. Overrides the shape - a taller `line` is one thick bar, not two. |

<!-- /generated:api -->

## Notes

`width` and `height` are plain inline styles, applied after the shape's own
CSS - they win regardless of `shape`, including on `circle`, where passing
only one of the two stretches it into an ellipse rather than keeping it
round. There is no prop for the shimmer's speed or the corner radius; both
come from the shape alone.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="skeleton" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

A card grid before its data has arrived, shaped like the cards it is about
to become - same avatar circle, same two lines, same gap - so nothing jumps
when the real content lands.

```tsx
import { Skeleton } from "@sushindustries/ui";

export function PackageGridLoading() {
	return (
		<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}>
			{Array.from({ length: 6 }, (_, index) => (
				<div key={index} className="card p-4 flex flex-col gap-3">
					<Skeleton shape="circle" />
					<Skeleton shape="line" width="80%" />
					<Skeleton shape="line" width="50%" />
				</div>
			))}
		</div>
	);
}
```

## What this example is not

The count and shape here match one specific grid. A real loading state should
mirror whatever it is standing in for - matching card count is a guess made
easier, not a rule the component enforces.
