Skip to content
Andrew SmithSolutions
← Component library

Skeleton

Feedback

Loading placeholders in three shapes, announced once for the whole region instead of once per grey box.

aria-busyLayout shiftLoadingCSS only — no dependency

Preview

Loading

Controls

Notes

One announcement — aria-busy plus a visually hidden "Loading" — with every shape aria-hidden. Nine announced grey rectangles is worse than silence.

The shapes have to match the layout they stand in for. A skeleton that lies about the size of the content is a layout shift with extra steps, and it lands exactly as the reader starts reading.

The sweep is a masked pseudo-element translating across the box, so it animates transform and never repaints what it crosses.

Under reduced motion the tint stays and the sweep stops. The page still reads as loading; it just stops moving.

Source

Show
import { cn } from "@/lib/cn";

/**
 * Loading placeholders.
 *
 * ONE announcement for the whole region — aria-busy plus a visually hidden
 * "Loading" — and every shape aria-hidden. Nine announced grey boxes is worse
 * than silence.
 *
 * The shapes have to match the layout they stand in for. A skeleton that lies
 * about the size of the content is a layout shift with extra steps, and the
 * reader pays for it at exactly the moment they start reading.
 */
const css = `
@keyframes skeleton-sweep { to { translate: 100% 0; } }
.skeleton { position: relative; overflow: hidden; background: var(--paper-sunken); }
.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  translate: -100% 0;
  /* Transform only — the sweep never repaints the box it crosses. */
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--ink) 9%, transparent),
    transparent
  );
  animation: skeleton-sweep 1.5s ease-in-out infinite;
}
.skeleton-still::after { display: none; }
/* The tint stays, the sweep stops. The page still reads as loading. */
@media (prefers-reduced-motion: reduce) {
  .skeleton::after { display: none; }
}
`;

function Bar({ className, shimmer }: { className?: string; shimmer: boolean }) {
  return (
    <div
      aria-hidden="true"
      className={cn("skeleton rounded-md", !shimmer && "skeleton-still", className)}
    />
  );
}

export type SkeletonVariant = "text" | "card" | "list";

export function Skeleton({
  variant = "card",
  lines = 3,
  shimmer = true,
  className,
}: {
  variant?: SkeletonVariant;
  lines?: number;
  shimmer?: boolean;
  className?: string;
}) {
  const rows = Math.max(1, lines);

  return (
    <div
      role="status"
      aria-busy="true"
      className={cn("w-full", className)}
    >
      <style href="skeleton-sweep" precedence="default">
        {css}
      </style>
      <span className="sr-only">Loading</span>

      {variant === "text" && (
        <div className="space-y-3">
          {Array.from({ length: rows }, (_, i) => (
            <Bar
              key={i}
              shimmer={shimmer}
              className={cn("h-3.5", i === rows - 1 ? "w-3/5" : "w-full")}
            />
          ))}
        </div>
      )}

      {variant === "card" && (
        <div className="rounded-xl border border-rule bg-paper-raised p-6">
          <Bar shimmer={shimmer} className="h-2.5 w-24" />
          <Bar shimmer={shimmer} className="mt-5 h-6 w-2/3" />
          <div className="mt-5 space-y-3">
            {Array.from({ length: rows }, (_, i) => (
              <Bar
                key={i}
                shimmer={shimmer}
                className={cn("h-3.5", i === rows - 1 ? "w-1/2" : "w-full")}
              />
            ))}
          </div>
        </div>
      )}

      {variant === "list" && (
        <ul className="divide-y divide-rule">
          {Array.from({ length: rows }, (_, i) => (
            <li key={i} className="flex items-center gap-4 py-4">
              <Bar shimmer={shimmer} className="size-9 shrink-0 rounded-full" />
              <div className="min-w-0 flex-1 space-y-2.5">
                <Bar shimmer={shimmer} className="h-3.5 w-2/5" />
                <Bar shimmer={shimmer} className="h-3 w-4/5" />
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default function Demo({
  variant = "card",
  lines = "3",
  shimmer = true,
}: {
  variant?: SkeletonVariant;
  lines?: string;
  shimmer?: boolean;
}) {
  return (
    <div className="w-full max-w-sm">
      <Skeleton variant={variant} lines={Number(lines) || 3} shimmer={shimmer} />
    </div>
  );
}

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.