Skeleton
SourceA loading placeholder that mirrors the shape of the content it stands in for, so the layout doesn't jump when the real data arrives. Compose Skeleton.Box, Skeleton.Circle, and Skeleton.Text inside a Skeleton.Root — an accessible loading region — and the whole group shimmers as one.
Basic
A Skeleton.Root wraps the placeholders in an accessible role="status" region that announces "Loading" once. Drop a Skeleton.Text inside for a paragraph of lines.
"use client";
import { Skeleton } from "@stridge/noctis/skeleton";
export default function SkeletonBasic() {
return (
<Skeleton.Root className="w-64">
<Skeleton.Text lines={3} />
</Skeleton.Root>
);
}
Shapes
Three shapes cover most content: Skeleton.Box (a rectangle for images, cards, or thumbnails), Skeleton.Circle (an avatar or icon disc), and Skeleton.Line (a single text bar). Box and circle are sized by you — set style/className dimensions to match what they stand in for.
"use client";
import { Skeleton } from "@stridge/noctis/skeleton";
export default function SkeletonShapes() {
return (
<div className="flex items-center gap-6">
<Skeleton.Circle style={{ width: 56, height: 56 }} />
<Skeleton.Box style={{ width: 120, height: 80 }} />
<div className="w-40">
<Skeleton.Line />
</div>
</div>
);
}
Text
Skeleton.Text renders a stack of line bars — lines sets how many (default 3). The last line is drawn shorter so the block reads as ragged prose, and the line height tracks the surrounding font size.
"use client";
import { Skeleton } from "@stridge/noctis/skeleton";
export default function SkeletonTextExample() {
return (
<div className="flex w-72 flex-col gap-6">
<Skeleton.Text lines={2} />
<Skeleton.Text lines={4} />
</div>
);
}
Variants
variant is the animation: a moving shimmer sweep (the default), a gentle pulse fade, or a static none. Set it on Skeleton.Root and every shape inside inherits it, or set it per shape. Every variant respects prefers-reduced-motion and falls back to a calm static fill.
"use client";
import { Skeleton } from "@stridge/noctis/skeleton";
const VARIANTS: Skeleton.Variant[] = ["shimmer", "pulse", "none"];
export default function SkeletonVariants() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
{VARIANTS.map((variant) => (
<div key={variant} className="flex flex-col gap-2">
<span className="text-mini text-muted">{variant}</span>
<Skeleton.Root variant={variant} className="flex flex-row items-center gap-3">
<Skeleton.Circle style={{ width: 40, height: 40 }} />
<div className="flex-1">
<Skeleton.Text lines={2} />
</div>
</Skeleton.Root>
</div>
))}
</div>
);
}
Custom line widths
Compose Skeleton.Line directly when you want per-line widths instead of the uniform Skeleton.Text block — a short heading line over full body lines, say. Each line still inherits the group's animation.
"use client";
import { Skeleton } from "@stridge/noctis/skeleton";
export default function SkeletonCustomLines() {
return (
<Skeleton.Root className="w-72">
<Skeleton.Line style={{ width: "40%" }} />
<Skeleton.Line />
<Skeleton.Line />
<Skeleton.Line style={{ width: "70%" }} />
</Skeleton.Root>
);
}
A media card
Mirror the real layout — a leading avatar disc beside a title and meta lines, with a banner box above — so the placeholder occupies the same space the loaded card will.
"use client";
import { Skeleton } from "@stridge/noctis/skeleton";
export default function SkeletonCard() {
return (
<Skeleton.Root label="Loading article" className="w-72 rounded-lg border border-border p-4">
<Skeleton.Box style={{ height: 140 }} />
<div className="flex items-center gap-3 pt-1">
<Skeleton.Circle style={{ width: 36, height: 36 }} />
<div className="flex-1">
<Skeleton.Text lines={2} />
</div>
</div>
</Skeleton.Root>
);
}
Swapping in content
The skeleton stands in while data loads; once it's ready, unmount the whole group and render the real content in its place. Because Skeleton.Root is a live region, screen readers hear "Loading" when it mounts and the real content when it replaces it.
"use client";
import { Avatar } from "@stridge/noctis/avatar";
import { Button } from "@stridge/noctis/button";
import { Skeleton } from "@stridge/noctis/skeleton";
import { useState } from "react";
export default function SkeletonSwap() {
const [loading, setLoading] = useState(true);
return (
<div className="flex flex-col items-start gap-4">
<Button variant="secondary" size="sm" onClick={() => setLoading((v) => !v)}>
{loading ? "Show content" : "Show skeleton"}
</Button>
<div className="w-72">
{loading ? (
<Skeleton.Root label="Loading profile" className="flex flex-row items-center gap-3">
<Skeleton.Circle style={{ width: 40, height: 40 }} />
<div className="flex-1">
<Skeleton.Text lines={2} />
</div>
</Skeleton.Root>
) : (
<div className="flex flex-row items-center gap-3">
<Avatar.Root size="md">
<Avatar.Fallback>AS</Avatar.Fallback>
</Avatar.Root>
<div className="flex-1">
<p className="font-medium">Ada Stridge</p>
<p className="text-small text-muted">Platform engineer</p>
</div>
</div>
)}
</div>
</div>
);
}
Accessibility
Skeleton.Root is the accessible loading region — a role="status" live region (implicitly aria-live="polite") marked aria-busy="true", carrying a visually-hidden "Loading" name so assistive tech announces the load once. Override that name with label (e.g. label="Loading profile"); it resolves through the locale catalog. The placeholder shapes are decorative (aria-hidden), so the loading state is conveyed once by the region, not repeated per shape.
When the data arrives, unmount the skeleton and render the real content — the polite live region then announces the update. A standalone shape used without a Skeleton.Root is purely decorative; wrap it in your own aria-busy region if it needs to announce.
Anatomy
Compose the placeholder from its parts. Each shape also works standalone (for example via Skeleton.Box.props(...) spread onto a foreign element).
Skeleton.Root— the accessible loading group; owns thevariantshared to its shapes and announces the load.Skeleton.Box— a rectangular placeholder. Fills its inline space; set its height to match the content.Skeleton.Circle— a circular placeholder for an avatar or icon. Sized by you.Skeleton.Text— a stack ofSkeleton.Lines (thelinescount); a paragraph placeholder.Skeleton.Line— a single text-line bar, for per-line composition.
Every rendered part carries a data-slot (noctis-skeleton on the group, noctis-skeleton-box/-circle/-text/-line on the shapes) for host-side styling — pair it with the data-variant axis the shapes stamp, off which the precompiled skeleton.css keys each shimmer/pulse animation.
On surfaces
The same placeholder re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. The fill is the surface-adaptive well overlay, so it stays visible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every skeleton in that region retunes — e.g. .list { --noctis-skeleton-shimmer-animation-duration: var(--noctis-duration-slow); } quickens the sweep beneath it. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table. Expand a row for the full type and description.