Skeleton Morph

PreviousNext

A skeleton whose geometry is the content's geometry, so the arrival is a crossfade in place rather than a rectangle swapped for real content and everything jumping.

Installation

pnpm dlx shadcn@latest add https://matos-ui.com/r/skeleton-morph.json

Usage

import { SkeletonMorph, Skel } from '@/components/matos-ui/skeleton-morph'
<SkeletonMorph loading={query.isPending}>
  <div className="flex items-center gap-3">
    <Skel className="size-12 rounded-full">
      <Avatar src={user.avatar} />
    </Skel>
    <div className="space-y-1.5">
      <Skel className="h-5 w-40">
        <p className="truncate font-medium text-sm">{user.name}</p>
      </Skel>
      <Skel className="h-4 w-28">
        <p className="truncate text-muted-foreground text-xs">{user.role}</p>
      </Skel>
    </div>
  </div>
</SkeletonMorph>

You lay the screen out once and wrap each data-bound piece in a Skel. While loading, each Skel shows a shimmer of the exact size and radius of its final content; when the query resolves, the shimmer crossfades out and the content crossfades in — in the same place.

Why it doesn't jump

The registry's plain skeleton is a rectangle — the content arrives and everything shifts, which makes the interface look fast without being fast. Matching the shape removes the shift.

  • A Skel's className is the box — the shimmer's, and the frame the content is clipped to. Size it to match the content (h-5 w-40, size-10 rounded-full, aspect-[2/3]) and clamp the content to fit (truncate, line-clamp-3, object-cover).
  • The box is the same in both phases, so the swap is a pure crossfade in place — no reflow. Any residual (a line of text a hair wider than you guessed) the layout on the root settles as a morph, not a jump.

Surface

Nothing here elevates. The shimmer fill is an alpha of the foreground, so a Skel reads on whatever rung its content sits on — the same offset as the content. That is the requirement, not a detail: a skeleton one rung off from its content is the jump it was supposed to prevent.

Motion

  • The shimmer sweeps on a gentle beat — unhurried, with a rest between passes, so it recedes rather than nags.
  • The crossfade to content is fast — it responds.
  • Under prefers-reduced-motion the shimmer is dropped entirely. It is ambient decoration, and the rule is to kill ambient decoration, not necessary motion — the flat fill still marks the box, and the crossfade still runs.

A skeleton that never resolves

A Skel's own loading prop overrides the boundary's. A lone Skel with loading and no SkeletonMorph around it is a permanent shimmer box — the cover fallback in Cover Image, where the image is never coming.

<Skel loading className="aspect-[2/3] rounded-lg" />

Reference

SkeletonMorph Props

PropTypeDefaultDescription
loadingbooleanWhile true, every Skel inside shows its shimmer.
childrenReactNodeYour layout, with a Skel around each data piece.
as"div" | "span""div"Root element.

Skel Props

PropTypeDefaultDescription
classNamestringThe box: the shimmer's size and radius, and the content frame.
childrenReactNodeThe real content.
loadingbooleancontextOverrides the boundary's loading.
as"span" | "div""span"Use "div" for a block-level region.