Skip to content

Latest commit

 

History

History
183 lines (147 loc) · 5.29 KB

File metadata and controls

183 lines (147 loc) · 5.29 KB
title Examples
description Common patterns for using bones.

Suspense streaming

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>
  );
}

Preview a skeleton

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>
  );
}

Client-side data fetching

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>
  );
}

Multi-line text

data-bones-lines paints that many bars in one element, with a shorter last line:

<p data-bones-lines="3">{article?.excerpt}</p>

List placeholders

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.

Keep a label readable

<div className="row">
  <span data-bones-auto="off">Catch rate</span>
  <span>{species?.captureRate}</span>
</div>

Skeleton animations

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>

Delay and hold

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.