Feedback

Skeleton

The wait, and the moment it ends. Most skeletons are a grey box with a shimmer; the interesting part is what happens when the content arrives.

Usage

import { Skeleton } from '@carabine/ui/skeleton';
Loading
<Skeleton loading={!user} width={['70%', '100%', '86%']} height={11} gap={9}>
  <Profile user={user} />
</Skeleton>

An array of widths is the bars — one entry each. A line count and a width are two ways of saying the same thing, and they disagree the first time somebody sets both.

The swap is the component

Both states live in one grid cell, stacked, so nothing reflows when they trade places. The placeholder leaves over 140ms with a blur; the content arrives over 220ms without one and with no entrance of its own. Out faster than in, and the two overlap — which is what makes it a crossfade rather than a flicker of empty space between them.

The bars are the placeholder, not the point. If you take one thing from this component, take the grid cell.

Examples

Shapes

circle for an avatar, ratio for anything with a known aspect, fill for a box that is already sized by its parent. Under a ratio the height follows the width, so the reserved space is the real space and nothing jumps when the image lands.

<Skeleton circle height={44} />
<Skeleton width={['80%', '55%']} height={10} gap={8} />
<Skeleton ratio={16 / 9} radius={10} />

In a line of text

inline sits in a sentence instead of owning a block. It aligns on the baseline, which needed a nudge: a grid with no baseline-aligned items synthesises one from the border box, so the box's height was driving the alignment and the word climbed as the placeholder left.

alignItems: inline ? 'baseline' : undefined,          // on the wrapper
transform: inline ? 'translateY(0.14em)' : undefined, // on the bar

Deployed to

Loading
in 42 seconds.

<p>
  Deployed to{' '}
  <Skeleton loading={loading} inline width={72} height={11}>
    <strong>eu-west-1</strong>
  </Skeleton>{' '}
  in 42 seconds.
</p>

Customization

<Skeleton
  lines={3}          // how many bars, when width is not an array
  height={12}        // one bar's height; the diameter when circle
  last="62%"         // the last bar's width, when there is a stack
  gap={8}
  radius={6}
  shimmer={false}    // no travelling light
  placeholder={<YourShape />}   // a shape of your own, instead of the bars
  label="Loading the profile"
/>

Styling reference

Tailwind utilities plus skeleton.css, which carries the shimmer's keyframe and its per-bar phase — --i on each bar, so the light crosses the stack at an angle rather than every bar flashing at once.

prefers-reduced-motion drops the shimmer entirely and keeps the crossfade: the travelling light is decoration, but the swap carries the meaning.

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

Length is number | string — pixels, or any CSS length.

PropTypeDefault
loadingbooleantruefalse swaps to children
childrenReactNodeWhat is coming. Without it, this is a shape
placeholderReactNodeA shape of your own, instead of the bars
linesnumber1How many bars
widthLength | Length[]'100%'An array is the bars, one entry each
heightLength12One bar’s height; the diameter when circle
lastLength'62%'The last bar’s width, when there is a stack
gapnumber8Between the bars
radiusLength6
circlebooleanfalseOne round bar, height across
rationumber16 / 9. The height follows the width
fillbooleanfalseTake the parent, for a box that is already sized
inlinebooleanfalseSit in a line of text instead of owning a block
shimmerbooleantrueThe light travelling across
labelstring'Loading'Read out while the placeholder is up
theme'dark' | 'light''dark'