Skip to content
cennge-ui
GitHubv0.3.1, changelog

Skeleton

Grey shapes where content is about to be, so the page keeps its layout while it loads.

  1. Switch Loaded on: each placeholder had the size of the content inside it, so nothing moves.
  2. While loading, the placeholders are hidden from screen readers; the card says aria-busy instead.
  3. A soft shimmer crosses each shape. With reduced motion it stops and the grey stays.
Live: switch it.

Field marks

Import
import { Skeleton } from "cennge-ui";
Size
0.5 KB gzip for this import, its dependencies aside
Pattern
A <div aria-hidden>; once loaded, only its children
State
isLoaded: off shows the placeholder, on shows the children
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Sizing
From your classes (h-4 w-32), or from the children it wraps

When to use#

Use a Skeleton when you know the shape of what is loading: a repository card, a list of releases, a profile. The reader sees where things will be, and nothing jumps when the data arrives. When the shape is unknown, or the wait is for an action rather than content, a spinner is more honest.

Similar#

  • Spinner says that something is at work, without promising a shape.
  • Progress shows how much of a task is done.
  • EmptyState is what to show once loading is over and there is nothing.

Shapes made with classes#

With no content to wrap yet, size each placeholder with classes: h-4 w-2/5 for a line, size-10 rounded-full for an avatar. Mark the list aria-busy while it loads.

shapes.tsx
import { Skeleton } from "cennge-ui";

export default function ReleaseListSkeleton() {
  return (
    <ul aria-busy="true" className="flex w-full max-w-md flex-col gap-5">
      {[0, 1, 2].map((row) => (
        <li key={row} className="flex items-center gap-3">
          <Skeleton className="size-10 shrink-0 rounded-full bg-default-bg" />
          <div className="flex flex-1 flex-col gap-2">
            <Skeleton className="h-4 w-2/5 bg-default-bg" />
            <Skeleton className="h-3 w-4/5 bg-default-bg" />
          </div>
          <Skeleton className="h-8 w-20 rounded-medium bg-default-bg" />
        </li>
      ))}
    </ul>
  );
}

Wrapping the real content#

Put the content inside and flip isLoaded. While loading, the children are rendered but invisible, so the placeholder takes their exact size; once loaded, the Skeleton renders the children alone. w-fit keeps a line's placeholder as wide as its text.

Latest release

cennge-ui 0.3.1

reload.tsx
import { useEffect, useState } from "react";
import { Button, Skeleton } from "cennge-ui";

export default function LatestRelease() {
  const [isLoaded, setLoaded] = useState(true);

  useEffect(() => {
    if (isLoaded) return;
    // Stands in for the request.
    const timer = setTimeout(() => setLoaded(true), 1500);
    return () => clearTimeout(timer);
  }, [isLoaded]);

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <div aria-busy={!isLoaded} className="flex flex-col gap-1.5 rounded-large bg-surface-3 p-4">
        <Skeleton isLoaded={isLoaded} className="w-fit bg-default-bg">
          <p className="text-sm text-muted">Latest release</p>
        </Skeleton>
        <Skeleton isLoaded={isLoaded} className="w-fit bg-default-bg">
          <p className="text-2xl font-semibold">cennge-ui 0.3.1</p>
        </Skeleton>
      </div>
      <Button variant="flat" isDisabled={!isLoaded} onClick={() => setLoaded(false)} className="self-start">
        Reload
      </Button>
    </div>
  );
}

Wrap block elements, as here. Around an inline element, such as a Badge on its own, the placeholder takes the height of a line of text and can be a few pixels taller than the element.

Styling#

The placeholder is filled with surface-3, the same surface as a Card, and in the light theme that is lighter than the panels around it. Inside a card, or on a light panel, give it a tint that shows on any surface: the examples on this page pass bg-default-bg.

Props#

PropTypeDefault
isLoaded
booleanShow children instead of the placeholder once data is ready.
–

Also takes every attribute of <div>.