bones

Introduction

Skeleton loaders designed for React Server Components and streaming.

~4.16 kB gzipped

0 dependencies

RSC ready

With React Server Components, your component renders once on the server. There's no re-render from "loading" to "loaded," so {data || <Skeleton />} doesn't work. Instead, you write your component, then write a second component that looks like it but shows placeholder blocks, and wire the two together with <Suspense>.

The problem with Suspense fallbacks

Every async component needs a matching skeleton component. The two look alike but share no code, so they fall out of sync over time.

PokemonCard.tsx — two components that must stay in sync
async function PokemonCard() {
  const data = await fetchPokemon();

  return (
    <div className="card">
      <img className="card-image" src={data.sprite} alt={data.name} />
      <h3 className="card-name">{data.name}</h3>
      <div className="card-types">
        {data.types.map((t) => (
          <span key={t} className="badge">
            {t}
          </span>
        ))}
      </div>
    </div>
  );
}

function PokemonCardSkeleton() {
  return (
    <div className="card">
      <div className="skeleton card-image" />
      <div className="skeleton card-name" />
      <div className="card-types">
        <div className="skeleton badge" />
        <div className="skeleton badge" />
      </div>
    </div>
  );
}
Page.tsx
function Page() {
  return (
    <Suspense fallback={<PokemonCardSkeleton />}>
      <PokemonCard />
    </Suspense>
  );
}

Add a field to the card? You have to update both components. Rename a class? Both. Change the layout? Both. The skeleton is a copy of your UI that contributes nothing except loading state.

It gets worse fast. A dashboard with three async components means three skeleton components:

Suspense — one boundary, three skeletons
function Dashboard() {
  return (
    <Suspense
      fallback={
        <>
          <UserCardSkeleton />
          <StatsSkeleton />
          <ActivityFeedSkeleton />
        </>
      }
    >
      <UserCard />
      <Stats />
      <ActivityFeed />
    </Suspense>
  );
}
Bones — no skeletons to maintain
function Dashboard() {
  return (
    <Bones>
      <UserCard />
      <Stats />
      <ActivityFeed />
    </Bones>
  );
}

How Bones works

<Bones> renders the same component tree as its own Suspense fallback. It sets a loading flag via React.cache(), and every createBones() call in the tree checks that flag. While loading, bone() returns HTML attributes that apply skeleton styles with CSS. Once the data resolves, it returns an empty object and your component renders normally. No hooks, no context, no 'use client'.

The children don't know they're inside a fallback. You could get the same effect with React Context, but a context provider is a client component, and adding 'use client' defeats the point.

PokemonCard.tsx
import { createBones } from "@lovo/bones";

function PokemonCard() {
  const { bone, data, repeat } = createBones(fetchPokemon());

  return (
    <div className="card">
      <img
        className="card-image"
        src={data?.sprite}
        alt={data?.name ?? "Pokemon"}
        width={120}
        height={120}
        {...bone("block")}
      />
      <h3 className="card-name" {...bone("text", { length: 6 })}>
        {data?.name}
      </h3>
      <div className="card-types">
        {repeat(data?.types, 2, (type, i) => (
          <span key={type ?? i} className="badge" {...bone("text", { contained: true, length: 7 })}>
            {type}
          </span>
        ))}
      </div>
    </div>
  );
}
cubone

cubone

ground
Pokemon

LoadedSkeleton

Same component, same markup. The skeleton can't get out of sync with the real UI because there's only one version of it.

Key ideas

  • Works in Server Components without hooks, context, or 'use client'.
  • One component handles both loading and loaded states.
  • Pass data or a promise. Bones wires up Suspense for you.
  • Skeletons are pure CSS, themed with custom properties.
  • Loading elements get aria-busy="true" automatically.

Nested and independent loading

All components inside a <Bones> boundary show skeletons until the slowest promise resolves. If you want components to load independently, wrap each one in its own <Bones>:

function Dashboard() {
  return (
    <>
      <Bones>
        <UserCard />
      </Bones>
      <Bones>
        <Stats />
      </Bones>
      <Bones>
        <ActivityFeed />
      </Bones>
    </>
  );
}

What about a loading prop?

You don't need two components to solve this. You could keep both states in one component with a loading prop:

Single component with loading prop
function PokemonCard({ loading }: { loading?: boolean }) {
  const data = loading ? undefined : use(fetchPokemon());

  return (
    <div className="card">
      {loading ? <div className="skeleton skeleton-image" /> : <img src={data.sprite} />}
      {loading ? <div className="skeleton skeleton-title" /> : <h3>{data.name}</h3>}
      {loading
        ? Array.from({ length: 2 }, (_, i) => <div key={i} className="skeleton skeleton-badge" />)
        : data.types.map((t) => <span key={t}>{t}</span>)}
    </div>
  );
}

function Page() {
  return (
    <Suspense fallback={<PokemonCard loading />}>
      <PokemonCard />
    </Suspense>
  );
}

This avoids a separate file, but every element still has two branches that can get out of sync. loading also has to be passed through every layer of the tree. If a user card lives inside a sidebar inside a dashboard, all three components need the prop even though only the card uses it.

With createBones(), a component at any depth gets skeletons without prop drilling:

Loading prop — two branches per element
function PokemonCard({ loading }: { loading?: boolean }) {
  const data = loading ? undefined : use(fetchPokemon());

  return (
    <div className="card">
      {loading ? <div className="skeleton skeleton-image" /> : <img src={data.sprite} />}
      {loading ? <div className="skeleton skeleton-title" /> : <h3>{data.name}</h3>}
      {loading
        ? [1, 2].map((i) => <div key={i} className="skeleton skeleton-badge" />)
        : data.types.map((t) => <span key={t}>{t}</span>)}
    </div>
  );
}

// Page.tsx — you still manage loading state
function Page() {
  return (
    <Suspense fallback={<PokemonCard loading />}>
      <PokemonCard />
    </Suspense>
  );
}
Bones — single code path
function PokemonCard() {
  const { bone, data, repeat } = createBones(fetchPokemon());

  return (
    <div className="card">
      <img src={data?.sprite} alt="" {...bone("block")} />
      <h3 {...bone("text", { length: 6 })}>{data?.name}</h3>
      {repeat(data?.types, 2, (item, i) => (
        <span key={item ?? i} className="badge">
          {item}
        </span>
      ))}
    </div>
  );
}

// Page.tsx — no loading prop, no separate fallback
function Page() {
  return (
    <Bones>
      <PokemonCard />
    </Bones>
  );
}

On this page