@leonnebrito/react-ghost
v0.1.0
Published
Skeleton screens generated automatically from your real React components — no hand-drawn placeholders, no layout drift.
Maintainers
Readme
react-ghost
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.
Install
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.
If this makes you nervous
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.
Why not react-loading-skeleton / react-content-loader?
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.
API
<Skeleton when shape children hints fallback count animation>
| 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'. |
useGhost(data, isLoading)
For data that already comes from a parent and can't be reshaped into a
render prop: returns a ghost while isLoading, data otherwise.
useIsSkeleton()
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.
<Skeleton.Ignore>
Renders nothing while inside a ghost cycle; renders children normally once
when is false.
useSkeletonQuery(queryKey, queryFn, options?) — react-ghost/tanstack
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.
Known limitations
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
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.
License
MIT
