Skeleton screens generated automatically from your real components. No hand-drawn placeholders living next to the real UI, silently rotting.
<Skeleton when={isLoading} shape={data}>
{(user) => <UserCard user={user} />}
</Skeleton>While isLoading is true, UserCard renders for real — but every value
it reads off user (user.name, user.avatar, user.isAdmin && …) comes
from a fake "ghost" object instead of your data, and materializes as a
shimmer bar sized to match. Same component, same markup, same layout.
When the real data lands, isLoading flips to false and UserCard
renders normally — nothing else changes.
npm install react-ghostimport { Skeleton } from 'react-ghost'
import 'react-ghost/style.css'react-ghost/style.css is the shimmer/pulse CSS. Skip it if you'd rather
supply your own styles for [data-sk] elements.
It's a reasonable reaction — the mechanism is a Proxy pretending to be a React element. Two escape hatches exist for exactly that:
// Bail out of the engine entirely and render your own placeholder:
<Skeleton when={isLoading} fallback={<MySkeletonCard />}>
{(user) => <UserCard user={user} />}
</Skeleton>// A child that throws while reading ghost data never blanks the tree —
// it falls back to a generic shimmer bar instead. This isn't opt-in,
// it's how <Skeleton> behaves by default.If neither is enough, don't use this library for that component — it composes fine alongside hand-written skeletons for the cases that need them.
Those solve drawing a skeleton. This solves keeping it in sync: there is
no second component to forget to update when the real one changes, because
there is no second component — <Skeleton> renders the real one, fed fake
data.
| Prop | Type | Notes |
|---|---|---|
when |
boolean |
false renders children(shape) as-is — the library gets out of the way entirely. |
shape |
T (optional) |
Real data — from cache, a previous render, or the actual query result once when is false. Sharpens heuristics: a number stays a number bar even if the field is named title. |
children |
(data: T) => ReactNode |
Render prop. data is a ghost while when is true, shape once it's false. |
hints |
Record<string, Hint> |
Per-path override, e.g. { 'user.title': { kind: 'number', digits: 3 } }. |
fallback |
ReactNode |
Bypasses the engine completely — see above. |
count |
number |
Items array-ghost operations (map/filter/…) produce. Default 3. |
animation |
'shimmer' | 'pulse' | 'none' |
Default 'shimmer'. |
For data that already comes from a parent and can't be reshaped into a
render prop: returns a ghost while isLoading, data otherwise.
Read inside a child component to skip side effects (typically a fetch) while rendering as part of a ghost cycle:
function Avatar({ src }: { src: string }) {
const isSkeleton = useIsSkeleton()
useEffect(() => {
if (!isSkeleton) preload(src)
}, [isSkeleton, src])
...
}This is cooperative, not automatic — a child that doesn't check
useIsSkeleton() will still fetch during a ghost cycle.
Renders nothing while inside a ghost cycle; renders children normally once
when is false.
Thinnest possible wiring over TanStack Query — optional peer dependency, isolated to its own entry point so the core package never requires it.
import { useSkeletonQuery } from 'react-ghost/tanstack'
function Profile({ id }: { id: string }) {
const { Skeleton } = useSkeletonQuery(['user', id], () => fetchUser(id))
return <Skeleton>{(user) => <UserCard user={user} />}</Skeleton>
}Everything useQuery returns (data, isLoading, refetch, …) passes
through unmodified alongside the bound Skeleton.
Array.isArray(ghost)isfalse— pass ahintsoverride witharray: trueif code depends on it directly.- A ghost isn't callable as a function (
ghost()throws); calling a method on one (ghost.map(...),ghost.toUpperCase()) works fine. - A ghost used as a plain attribute (
<img src={user.avatar}>) has no element of its own to shimmer — it resolves to a transparent pixel, so it disappears rather than animating. Give the wrapper its own background.
See SDD.md for the full design rationale, the hostile-case test suite,
and every limitation with the reasoning behind it.
demo/ is a real Vite + Tailwind + shadcn/ui app built against this
library's actual source (not a mock) — clone the repo and run
npm install && npm run dev inside demo/ to see it live.
MIT