← All components

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Skeleton — usage16 lines
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

PropNotes
Skeleton classNamestringSize and radius — `h-9 w-full rounded-[10px]` for a field.
SkeletonText linesnumberThe last line is shorter, the way a paragraph ends.
SkeletonText size"title" | "body" | "meta"Which text it stands for; sets the bar and the line height.
SkeletonCircle sizenumberMatch 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.

packages/ui/src/components/layout/Skeleton.tsx72 lines
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 }} />;
}