Display

Skeleton

Placeholder shapes that hold the layout while content loads: lines of text, blocks and circles.

Built on
lh unitprefers-reduced-motion
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.3 kB
Dependencies
React
Try its options

Loading its options.

Code
import { Skeleton } from "@/components/equip/skeleton";

export default function Demo() {
  return (
    <div aria-busy="true" style={{ display: "grid", gap: 12, width: 200 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <Skeleton shape="circle" />
        <Skeleton lines={2} style={{ flex: 1, fontSize: 13 }} />
      </div>
      <Skeleton shape="block" height={48} />
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div aria-busy="true" style="display:grid;gap:12px;width:200px">
  <div style="display:flex;align-items:center;gap:10px">
    <div aria-hidden="true" class="eq-skeleton" data-shape="circle"></div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="flex:1;font-size:13px">
      <span class="eq-skeleton-line"></span>
      <span class="eq-skeleton-line"></span>
    </div>
  </div>
  <div aria-hidden="true" class="eq-skeleton" data-shape="block" style="height:48px"></div>
</div>

Examples

Shapes

Code
import { Skeleton } from "@/components/equip/skeleton";

export default function Example() {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 16, width: "100%", maxWidth: 320 }}>
      <Skeleton shape="circle" width={42} />
      <Skeleton shape="block" width={96} height={64} />
      <Skeleton lines={3} style={{ flex: 1, fontSize: 14 }} />
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:flex;align-items:center;gap:16px;width:100%;max-width:320px">
  <div aria-hidden="true" class="eq-skeleton" data-shape="circle" style="width:42px"></div>
  <div aria-hidden="true" class="eq-skeleton" data-shape="block" style="width:96px;height:64px"></div>
  <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="flex:1;font-size:14px">
    <span class="eq-skeleton-line"></span>
    <span class="eq-skeleton-line"></span>
    <span class="eq-skeleton-line"></span>
  </div>
</div>

Sized by the text it replaces

Lines take their height from the inherited font and line height, so swapping in the real text does not shift the layout.

Nightly backups for eu-west-1 finished at 02:14 UTC and were copied to cold storage.

Code
import { Skeleton } from "@/components/equip/skeleton";

export default function Example() {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 24, width: "100%", maxWidth: 360, fontSize: 14, lineHeight: 1.5 }}>
      <div aria-hidden="true">
        <Skeleton lines={4} />
      </div>
      <p style={{ margin: 0 }}>
        Nightly backups for eu-west-1 finished at 02:14 UTC and were copied to cold storage.
      </p>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:24px;width:100%;max-width:360px;font-size:14px;line-height:1.5">
  <div aria-hidden="true">
    <div aria-hidden="true" class="eq-skeleton" data-shape="text">
      <span class="eq-skeleton-line"></span>
      <span class="eq-skeleton-line"></span>
      <span class="eq-skeleton-line"></span>
      <span class="eq-skeleton-line"></span>
    </div>
  </div>
  <p style="margin:0">Nightly backups for eu-west-1 finished at 02:14 UTC and were copied to cold storage.</p>
</div>

A loading list

The list carries aria-busy and a name; the skeletons inside are hidden, so there is nothing to read until the invoices arrive.

Code
import { Skeleton } from "@/components/equip/skeleton";

export default function Example() {
  return (
    <ul aria-busy="true" aria-label="Invoices" style={{ display: "grid", gap: 12, width: "100%", maxWidth: 280, margin: 0, padding: 0, listStyle: "none" }}>
      {[0, 1, 2].map((row) => (
        <li key={row} style={{ display: "flex", alignItems: "center", gap: 12, fontSize: 13 }}>
          <Skeleton shape="block" width={28} height={28} />
          <Skeleton style={{ flex: 1 }} />
          <Skeleton width={56} />
        </li>
      ))}
    </ul>
  );
}
HTMLworks in any framework, with the part's CSS
<ul aria-busy="true" aria-label="Invoices" style="display:grid;gap:12px;width:100%;max-width:280px;margin:0;padding:0;list-style:none">
  <li style="display:flex;align-items:center;gap:12px;font-size:13px">
    <div aria-hidden="true" class="eq-skeleton" data-shape="block" style="width:28px;height:28px"></div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="flex:1">
      <span class="eq-skeleton-line"></span>
    </div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="width:56px">
      <span class="eq-skeleton-line"></span>
    </div>
  </li>
  <li style="display:flex;align-items:center;gap:12px;font-size:13px">
    <div aria-hidden="true" class="eq-skeleton" data-shape="block" style="width:28px;height:28px"></div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="flex:1">
      <span class="eq-skeleton-line"></span>
    </div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="width:56px">
      <span class="eq-skeleton-line"></span>
    </div>
  </li>
  <li style="display:flex;align-items:center;gap:12px;font-size:13px">
    <div aria-hidden="true" class="eq-skeleton" data-shape="block" style="width:28px;height:28px"></div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="flex:1">
      <span class="eq-skeleton-line"></span>
    </div>
    <div aria-hidden="true" class="eq-skeleton" data-shape="text" style="width:56px">
      <span class="eq-skeleton-line"></span>
    </div>
  </li>
</ul>

Props

PropTypeDefaultWhat it does
shape"text" | "block" | "circle""text"Text draws lines sized to the surrounding font, block a rectangle, circle a disc.
linesnumber1Lines for the text shape. With more than one, the last runs short like a paragraph.
widthnumber | stringnonePixels or any CSS length. Text and block fill their container, circle defaults to 34px.
heightnumber | stringnonePixels or any CSS length for block. Text takes its height from the font size.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-skeletonA div with aria-hidden. Holds the lines for text, is the shape itself otherwise.
line.eq-skeleton-lineOne bar per line of the text shape.

Notes

  • Every skeleton is aria-hidden; the container it sits in owns the loading state with aria-busy="true", removed when the content arrives.
  • If the wait is long enough to matter, also announce it in words, for example a visually hidden "Loading invoices" in a role="status" region.
  • Text lines size themselves to the font and line height they inherit, so set the skeleton's font size to match the text it replaces.
  • The shimmer is the one motion here that runs on its own; under prefers-reduced-motion it stops and the shapes stay still.
  • Text line spacing uses the lh unit. Where it is missing the spacing falls back to em values that assume a line height of 1.4.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/skeleton.tsx37 lines
import type { ComponentProps } from "react";
import "./skeleton.css";

export type SkeletonProps = ComponentProps<"div"> & {
  /** `text` draws lines sized to the surrounding font, `block` a rectangle, `circle` a disc. */
  shape?: "text" | "block" | "circle";
  /** Number of lines for `text`. The last of several runs short, like a paragraph. */
  lines?: number;
  /** Width as a CSS length or pixels. Text and block fill their container by default. */
  width?: number | string;
  /** Height as a CSS length or pixels. Ignored by `text`, which takes its height from the font. */
  height?: number | string;
};

export function Skeleton({
  shape = "text",
  lines = 1,
  width,
  height,
  className,
  style,
  ...rest
}: SkeletonProps) {
  const count = Math.max(1, Math.floor(lines));
  return (
    <div
      aria-hidden="true"
      {...rest}
      className={["eq-skeleton", className].filter(Boolean).join(" ")}
      data-shape={shape}
      style={{ width, height: shape === "text" ? undefined : height, ...style }}
    >
      {shape === "text" &&
        Array.from({ length: count }, (_, i) => <span key={i} className="eq-skeleton-line" />)}
    </div>
  );
}
components/equip/skeleton.css68 lines
@layer equip {
  .eq-skeleton {
    box-sizing: border-box;
    display: block;
    user-select: none;
  }

  /* Lines are 0.8em bars on the inherited line height, so a text skeleton is exactly as tall
     as the text it stands in for. The em fallbacks assume a line height of 1.4. */
  .eq-skeleton[data-shape="text"] {
    display: grid;
    gap: 0.6em;
    gap: calc(1lh - 0.8em);
    padding-block: 0.3em;
    padding-block: calc((1lh - 0.8em) / 2);
  }

  .eq-skeleton-line {
    box-sizing: border-box;
    display: block;
    height: 0.8em;
    border-radius: var(--eq-radius-s);
  }

  .eq-skeleton-line:last-child:not(:first-child) {
    width: 62%;
  }

  .eq-skeleton[data-shape="block"] {
    height: var(--eq-h-l);
    border-radius: var(--eq-radius-m);
  }

  .eq-skeleton[data-shape="circle"] {
    width: var(--eq-h-m);
    aspect-ratio: 1;
    border-radius: var(--eq-radius-full);
  }

  .eq-skeleton:not([data-shape="text"]),
  .eq-skeleton-line {
    /* A transparent border turns into a visible outline in forced-colours mode,
       where backgrounds are dropped. */
    border: 1px solid transparent;
    background: linear-gradient(90deg, transparent 33%, var(--eq-tint) 50%, transparent 67%)
      var(--eq-tint-strong);
    background-size: 300% 100%;
    /* The one motion that runs on its own: a slow sweep that says "still loading". */
    animation: eq-skeleton-shimmer 2s ease-in-out infinite;
  }

  @keyframes eq-skeleton-shimmer {
    from {
      background-position: 100% 0;
    }

    to {
      background-position: 0 0;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .eq-skeleton:not([data-shape="text"]),
    .eq-skeleton-line {
      animation: none;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index