Skip to content
caliper ui docs

Components

Skeleton morph

Loading placeholders that turn into the real content one block at a time instead of being swapped out. The placeholders are the content's own boxes, so nothing moves when the data arrives.

Free
pnpm dlx shadcn@latest add @caliper/skeleton-morph

Profile

Loads in place, block by block.

Loading
Installs components/ui/skeleton-morph.tsxDepends on none beyond ReactMotion none in component source

When to use

Use it for

  • Cards, profiles and list rows whose layout is known before the data.
  • Anywhere a hard swap from skeleton to content makes the page flash.

Skip it when

  • Content whose size depends on the data; use `LoadingSwap`, which eases the height.
  • Waits under a quarter of a second, where any placeholder is noise.

Decisions

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Top to bottom
Blocks fill in document order with a short stagger, so the eye reads the arrival the way it will read the content.

Styling

Bones are the real boxes
Mark each block with data-bone and render stand-in text while loading; the placeholder takes the block's own size, so there is no layout shift on arrival.

Usage

Quiet while loading
The region is busy and the stand-in text is hidden from screen readers and the keyboard until it is real.

Installation

terminal
pnpm dlx shadcn@latest add @caliper/skeleton-morph

Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage

This is the code behind the preview above.

example.tsx
import { useEffect, useState, type ReactNode } from "react"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { SkeletonMorph } from "@/components/ui/skeleton-morph"

function Panel({ title, context, className = "max-w-md", children }: { title: string; context: string; className?: string; children: ReactNode }) {
  return (
    <section className={`grid w-full gap-5 rounded-overlay border border-border bg-background p-6 shadow-resting max-sm:p-4 ${className}`}>
      <div className="grid gap-1">
        <p className="font-medium">{title}</p>
        <p className="text-sm text-muted-foreground">{context}</p>
      </div>
      {children}
    </section>
  );
}

export function SkeletonMorphDemo() {
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    if (!loading) return;
    const timer = window.setTimeout(() => setLoading(false), 1400);
    return () => window.clearTimeout(timer);
  }, [loading]);
  return (
    <Panel title="Profile" context="Loads in place, block by block.">
      <SkeletonMorph loading={loading} className="grid gap-4">
        <div className="flex items-center gap-3">
          <span data-bone="round" className="inline-flex size-12 rounded-full">
            <Avatar name="Maya Chen" className="size-12" />
          </span>
          <div className="grid gap-1">
            <span data-bone className="justify-self-start font-medium">
              {loading ? "Loading name" : "Maya Chen"}
            </span>
            <span data-bone className="justify-self-start text-sm text-muted-foreground">
              {loading ? "Role and team" : "Design lead · Payments"}
            </span>
          </div>
        </div>
        <p data-bone className="text-sm leading-relaxed">
          {loading ? "A short biography goes here and runs onto a second line so the shape is right." : "Leads the checkout redesign. Previously built the card issuing flow and the merchant onboarding."}
        </p>
        <div className="grid grid-cols-3 gap-2">
          {[["Reviews", "48"], ["Shipped", "12"], ["Mentees", "3"]].map(([label, value]) => (
            <div key={label} data-bone className="grid gap-0.5 rounded-control border border-border p-3">
              <span className="text-lg font-semibold tabular-nums">{value}</span>
              <span className="text-xs text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
      </SkeletonMorph>
      <Button variant="secondary" size="sm" className="justify-self-start" onClick={() => setLoading(true)} disabled={loading}>
        Reload
      </Button>
    </Panel>
  );
}

API reference

SkeletonMorph

component

Also accepts the native props of its root element and a ref.

PropTypeDefaultDescription
loading *boolean—While true, every element marked `data-bone` inside shows as a placeholder of its own size.
staggernumber70Milliseconds between one block arriving and the next.

Accessibility

It is built on native elements and keeps aria-busy and aria-hidden in step with state.

No custom key handling; Tab and the native activation keys do the work.

Motion

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.

All Surfaces examples