Skeleton
Grey shapes of what is coming, crossed by one pass of light and a rest.
The idea
A sheen that never stops sliding says “progress”, which a skeleton cannot promise; one pass and a pause says “still coming” and lets the eye settle. The light is a transform on a pseudo-element, so a page of them costs the compositor, not paint, and under reduced motion it is gone. The shapes are the content's own — bars on the text's line height, a circle the avatar's size — so when the real thing lands in the same footprint nothing moves.
Composes Avatar.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Skeleton, SkeletonCircle, SkeletonText } from "mihirmodi-ui";
// Shapes in the content's own footprint, so nothing moves when it lands.
// Skeletons are aria-hidden; mark the region busy instead.
<ul aria-busy={loading}>
{loading ? (
<li className="flex h-9 items-center gap-3">
<SkeletonCircle size={36} />
<Skeleton className="h-[10px] w-2/5 rounded-full" />
</li>
) : (
rows
)}
</ul>
<SkeletonText lines={3} />Props
| Prop | Type | Notes |
|---|---|---|
| Skeleton classNamestring | string | Size and radius — `h-9 w-full rounded-[10px]` for a field. |
| SkeletonText linesnumber | number= 3 | The last line is shorter, the way a paragraph ends. |
| SkeletonText size"title" | "body" | "meta" | "title" | "body" | "meta"= "body" | Which text it stands for; sets the bar and the line height. |
| SkeletonCircle sizenumber | number= 36 | Match the avatar it stands for. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
import type { CSSProperties } from "react";
export type SkeletonProps = {
/** Size and radius through classes — `h-9 w-full rounded-[10px]` for a field. */
className?: string;
style?: CSSProperties;
};
/**
* A placeholder in the shape of what is coming.
*
* Grey, translucent so it reads on any surface, and crossed by one pass of
* light per cycle followed by a rest. A sheen that never stops sliding says
* "progress", which a skeleton cannot promise; a single pass and a pause says
* "still coming" and lets the eye settle in between. Under reduced motion the
* light is gone and the blocks simply sit there.
*
* The shapes should be the content's own: a line of text is a bar a little
* shorter than the text's line, a face is a circle the avatar's size, a row
* keeps the row's height. When the real thing arrives in the same footprint,
* nothing moves — which is the only thing a skeleton is for. Skeletons are
* hidden from assistive tech; mark the region they fill `aria-busy` instead.
*/
export function Skeleton({ className = "", style }: SkeletonProps) {
return <span aria-hidden className={`ui-skeleton block rounded-[8px] ${className}`} style={style} />;
}
export type SkeletonTextProps = {
/** How many lines. The last one is shorter, the way a paragraph ends. */
lines?: number;
/**
* The text it stands for: `body` is 14px on a 1.65 line, `meta` 12–13px on
* 1.5, `title` 14–16px at 500. The bar is the x-height-ish core of the
* line, so the block is as tall as the paragraph will be.
*/
size?: "title" | "body" | "meta";
className?: string;
};
const LINE: Record<NonNullable<SkeletonTextProps["size"]>, { bar: number; line: number }> = {
title: { bar: 11, line: 22 },
body: { bar: 9, line: 23 },
meta: { bar: 8, line: 19 },
};
/** A paragraph's worth of bars, spaced on the text's own line height. */
export function SkeletonText({ lines = 3, size = "body", className = "" }: SkeletonTextProps) {
const { bar, line } = LINE[size];
return (
<span aria-hidden className={`flex flex-col ${className}`}>
{Array.from({ length: lines }, (_, i) => (
<span key={i} className="flex items-center" style={{ height: line }}>
<Skeleton
className="rounded-full"
style={{ height: bar, width: lines > 1 && i === lines - 1 ? "62%" : size === "title" ? "48%" : "100%" }}
/>
</span>
))}
</span>
);
}
export type SkeletonCircleProps = {
/** Diameter in px — match the avatar it stands for (20, 28, 36, 48). */
size?: number;
className?: string;
};
/** A face that has not loaded yet. */
export function SkeletonCircle({ size = 36, className = "" }: SkeletonCircleProps) {
return <Skeleton className={`shrink-0 rounded-full ${className}`} style={{ width: size, height: size }} />;
}