caliper uidocs

Exploded view

Free

Pull 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.

Installs components/ui/exploded-view.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add @caliper/exploded-view

When 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 #

terminal
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.

example.tsx
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
PropTypeDefaultDescription
layers *readonly ExplodedLayer[]—
labelstring"Layers"Accessible name of the layer list.
spreadnumber—0 (flat) to 1 (exploded). Controlled when set.
defaultSpreadnumber1
onSpreadChange((spread: number) => void)—
widthnumber240Size of one plane in pixels.
heightnumber160
statusDepthStatus"ready"
messagesDepthMessages—
onRetry(() => void)—
disabledboolean—
classNamestring—

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.