Skip to content

Repository files navigation

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-ghost
import { 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) is false — pass a hints override with array: true if 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

About

Skeleton screens generated automatically from your real components. No hand-drawn placeholders living next to the real UI, silently rotting.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages