Grey shapes that stand where content is still loading and hold the height it will have (also called a placeholder or shimmer). Reach for it when you know the layout of what is coming, so nothing jumps when it arrives.
import { Skeleton } from "@umriss-ui/core";
A skeleton is hidden from screen readers. Mark the region that is loading with aria-busy, and say in words what loads where that matters.
A line per line of text, the last one shorter, so the placeholder has the height the text will have.
import { Skeleton, Stack } from "@umriss-ui/core";
export default function Lines() {
return (
<Stack gap={2} style={{ maxWidth: 420 }}>
<Skeleton />
<Skeleton />
<Skeleton width="60%" />
</Stack>
);
}
circle, width and height give it the shape of what comes – an avatar, a name, a line beneath it.
import { Skeleton, Stack } from "@umriss-ui/core";
export default function Shapes() {
return (
<Stack direction="row" gap={3} align="center" style={{ maxWidth: 420 }}>
<Skeleton circle width={32} height={32} />
<Stack gap={2} style={{ flex: 1 }}>
<Skeleton width="55%" height={14} />
<Skeleton width="35%" height={10} />
</Stack>
</Stack>
);
}
The placeholder rows are as tall as the rows that replace them, so nothing below jumps when the data arrives.
import { useState } from "react";
import { Button, Skeleton, Stack, Text } from "@umriss-ui/core";
const PEOPLE = [
{ name: "Maya Lindgren", role: "Product manager · Web" },
{ name: "Arjun Mehta", role: "Developer · Web" },
{ name: "Noah Fischer", role: "Designer · Web" },
];
function Avatar({ name }: { name: string }) {
return (
<span
aria-hidden="true"
style={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
background: "var(--u-color-surface-sunken)",
fontSize: "var(--u-text-xs)",
}}
>
{name.split(" ").map((part) => part[0])}
</span>
);
}
export default function AListLoading() {
const [loading, setLoading] = useState(true);
return (
<Stack gap={4} style={{ maxWidth: 420 }}>
<Stack gap={3} aria-busy={loading}>
{PEOPLE.map((person) => (
<Stack key={person.name} direction="row" gap={3} align="center" style={{ minHeight: 36 }}>
{loading ? (
<>
<Skeleton circle width={32} height={32} />
<Stack gap={2} style={{ flex: 1 }}>
<Skeleton width="50%" height={14} />
<Skeleton width="35%" height={10} />
</Stack>
</>
) : (
<>
<Avatar name={person.name} />
<Stack gap={1}>
<Text size="sm" weight="medium">
{person.name}
</Text>
<Text size="xs" tone="muted">
{person.role}
</Text>
</Stack>
</>
)}
</Stack>
))}
</Stack>
<Button size="sm" onClick={() => setLoading((l) => !l)} style={{ alignSelf: "flex-start" }}>
{loading ? "Show the loaded rows" : "Show the placeholders"}
</Button>
</Stack>
);
}
The keys of Button apply here.
Every skeleton is aria-hidden: a screen reader meets nothing where it stands, and nothing is announced when the content replaces it. The region that loads carries aria-busy from you, and a word where the wait matters.
With reduced motion the shimmer stops, and the shape stands in a still, faint grey.
SkeletonProps| Name | Type | Default | Description |
|---|---|---|---|
width | string | number | "100%" | Width, e.g. "120px" or "60%". Shown in: Configurator, Lines of text to come, Shapes and 2 more |
height | string | number | "12px" | Height, e.g. "12px" or 16. Shown in: Configurator, Shapes, Hold a list's place while it loads |
circle | boolean | false | Circular shape, e.g. for an avatar placeholder. Shown in: Configurator, Shapes, Hold a list's place while it loads |
ref | Ref<HTMLSpanElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
Also takes every attribute of <span>.
What umriss deliberately does not build, and why: ADR-0032.