| title | Examples |
|---|---|
| description | Common patterns for using bones. |
Pass the resolved data to the component and reuse it, busy and empty, as the fallback.
import { Suspense } from "react";
import type { ComponentProps } from "react";
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
return res.json();
}
async function User() {
const user = await fetchUser();
return <UserCard user={user} />;
}
export default function Page() {
return (
<Suspense fallback={<UserCard aria-busy="true" />}>
<User />
</Suspense>
);
}
function UserCard({ user, ...rest }: { user?: User } & ComponentProps<"div">) {
return (
<div {...rest}>
<img src={user?.avatar} width={64} height={64} alt="" />
<h2>{user?.name}</h2>
<p data-bones-lines="2">{user?.bio}</p>
</div>
);
}Render the component busy with no data. No promise, no boundary:
function SkeletonDemo() {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<UserCard user={mockUser} />
<UserCard aria-busy="true" />
</div>
);
}With React Query or SWR, set aria-busy from the loading flag:
import useSWR from "swr";
function UserCard() {
const { data: user, isLoading } = useSWR("/api/user", fetcher);
return (
<div aria-busy={isLoading ? "true" : undefined}>
<img src={user?.avatar} width={64} height={64} alt="" />
<h2>{user?.name}</h2>
<p data-bones-lines="2">{user?.bio}</p>
</div>
);
}data-bones-lines paints that many bars in one element, with a shorter last line:
<p data-bones-lines="3">{article?.excerpt}</p>CSS cannot add elements, so a list with no data needs a count:
function TagList({ pokemon, ...rest }: { pokemon?: Pokemon } & ComponentProps<"div">) {
return (
<div {...rest}>
<h3>{pokemon?.name}</h3>
<div style={{ display: "flex", gap: 4 }}>
{(pokemon?.types ?? Array.from({ length: 2 })).map((type, i) => (
<span key={type ?? i}>{type}</span>
))}
</div>
</div>
);
}Each empty span paints as a bar four characters wide. Block-level bars vary their width by position on their own.
<div className="row">
<span data-bones-auto="off">Catch rate</span>
<span>{species?.captureRate}</span>
</div>Bones shimmer by default. Set data-bones-animate on an element to change the animation for the bones inside it, or to none to keep them still. The attribute also works on a bone itself and on the busy element.
<div data-bones-animate="pulse">
<UserCard aria-busy="true" />
</div>When prefers-reduced-motion: reduce is active, the default and shimmer both become pulse. none stays still.
Set it on <body> to change every skeleton in your app, and on a section to override that:
<body data-bones-animate="shimmer">
<UserCard user={user} />
<div data-bones-animate="pulse">
<Sidebar items={items} />
</div>
</body>Suspense shows the skeleton as the first paint, so it never flashes. A region you mark busy around your own fetch can flash when the response is fast, and strobe when it is barely slow. This function sets aria-busy and inert after delay and, once shown, keeps them for at least minDuration. It hides inside a view transition where the browser has one and the user has not asked for reduced motion.
export function busy(region, { delay = 200, minDuration = 400 } = {}) {
let shownAt = 0;
const show = setTimeout(() => {
shownAt = Date.now();
region.setAttribute("aria-busy", "true");
region.toggleAttribute("inert", true);
}, delay);
return function done() {
clearTimeout(show);
if (shownAt === 0) return; // never shown: nothing to hide
const hide = () => {
region.removeAttribute("aria-busy");
region.removeAttribute("inert");
};
const remaining = Math.max(0, shownAt + minDuration - Date.now());
setTimeout(() => {
const still =
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
if (typeof document.startViewTransition === "function" && !still) {
document.startViewTransition(hide);
} else {
hide();
}
}, remaining);
};
}const done = busy(document.querySelector("#profile"));
try {
const profile = await (await fetch("/api/profile")).json();
render(profile);
} finally {
done();
}The finally keeps a failed request from leaving the region busy and inert. Leave the old content in place while the request runs. If bones show, the stylesheet sizes them from it; if the response beats delay, nothing changes on screen. A router that already has pending-state timing (TanStack Router's pendingMs and pendingMinMs, Vue Suspense's timeout) makes this unnecessary; use the router's. The recipe assumes one request per region at a time. If a newer request can start while one is in flight, keep the region's done() in your own state and call it when the latest request lands, instead of calling busy() again; an older done() would otherwise clear the newer request's busy state.