Exploded view
FreePull a composed interface apart along its depth axis so each layer can be named. Spread is one continuous value: at 0 it is the finished interface seen face on, at 1 it is the labelled stack. The list carries every name, and hovering or focusing a row lights its plane and dims the rest.
pnpm dlx shadcn@latest add @caliper/exploded-viewWhen to use #
Use it for
- Anatomy sections: what is in a dialog, a card or a menu, and in what order.
- Explaining stacking: page, scrim, surface, content, focus.
Skip it when
- Decoration. If the layers are not a real stacking order, a flat diagram says it better.
Decisions #
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- Spread is a value, not a switch
- A slider and two buttons drive the same number, so people can stop halfway and compare how far apart the layers are.
- The list is the interface
- The planes are inert and hidden from assistive technology. Names, descriptions and focus live in the list, so nothing depends on seeing the 3D.
Styling
- One perspective, on the stage
- The page has none. The scene sets 1000px on its own wrapper, and the object turns about 58 degrees, never a full rotation.
Installation #
pnpm dlx shadcn@latest add @caliper/exploded-view
Adds its 7 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 { ExplodedView, type ExplodedLayer } from "@/components/ui/exploded-view" export function ExplodedViewDemo() { const layers: ExplodedLayer[] = [ { id: "page", name: "Page", description: "The canvas the dialog sits on.", children: ( <div className="absolute inset-0 rounded-[10px] bg-muted"> <i className="absolute left-3.5 top-[30px] h-2 w-2/5 rounded bg-foreground/10" /> <i className="absolute left-3.5 top-[46px] h-2 w-3/5 rounded bg-foreground/10" /> </div> ) }, { id: "scrim", name: "Scrim", description: "Dims the page and blocks it.", children: <div className="absolute inset-0 rounded-[10px] bg-foreground/20" /> }, { id: "surface", name: "Surface", description: "Frosted card with edge light.", children: <div className="absolute inset-x-[26px] inset-y-[18px] rounded-xl border border-border-strong bg-elevated" /> }, { id: "content", name: "Content", description: "Title, field and action.", children: ( <div className="absolute inset-0 text-[12px]"> <b className="absolute left-[42px] top-[30px] text-[13px] font-semibold">Invite people</b> <span className="absolute inset-x-[42px] top-14 h-7 rounded-lg bg-muted px-2 leading-7 text-muted-foreground shadow-[inset_0_0_0_1px_var(--border-strong)]">name@company.com</span> <span className="absolute bottom-[34px] right-[42px] h-[26px] rounded-lg bg-foreground px-3 font-medium leading-[26px] text-background">Send invite</span> </div> ) }, { id: "focus", name: "Focus ring", description: "Ember, always on top.", children: <span className="absolute bottom-[29px] right-[37px] h-9 w-[100px] rounded-[11px] border-2 border-brand" /> }, ]; return <div className="w-full max-w-3xl"><ExplodedView layers={layers} label="Layers of the invite dialog" /></div>; }
API reference #
ExplodedView
component
Accessibility #
It is built on native elements and keeps aria-hidden, aria-label, aria-valuetext and aria-pressed 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.