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.
pnpm dlx shadcn@latest add @caliper/skeleton-morphWhen 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-boneand 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
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.
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.
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.