Morphing modal
FreeA modal whose surface morphs between views, like a menu becoming a confirmation.
pnpm dlx shadcn@latest add https://caliperui.site/r/morphing-modal.jsonWhen to use #
Use it for
- Multi-step choices in one place, such as actions then confirm.
- You want the transition to explain the connection.
Skip it when
- A single static dialog is enough.
Installation #
terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/morphing-modal.json
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 { useState } from "react" import { Button } from "@/components/ui/button" import { MorphingModal } from "@/components/ui/morphing-modal" export function ProjectActionsModal() { const [view, setView] = useState<"actions" | "archive" | null>(null); return ( <div className="grid justify-items-center gap-3"> <Button variant="secondary" onClick={() => setView("actions")}>Project actions</Button> <MorphingModal open={view != null} onOpenChange={(next) => { if (!next) setView(null); }} label={view === "archive" ? "Archive project" : "Project actions"} view={view ?? "closed"}> {view === "archive" ? ( <div className="grid gap-3"> <p className="text-sm text-muted-foreground">Atlas moves to the archive. You can restore it from there.</p> <Button onClick={() => setView(null)}>Archive</Button> </div> ) : ( <div className="grid"> <button type="button" className="h-10 rounded-lg text-left text-sm hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring" onClick={() => setView("archive")}>Archive…</button> <button type="button" className="h-10 rounded-lg text-left text-sm hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring" onClick={() => setView(null)}>Rename</button> </div> )} </MorphingModal> </div> ); }
API reference #
MorphingModal
component
PropTypeDefault
open *boolean—
onOpenChange *(open: boolean) => void—
label *string—
view *string—
children *ReactNode—
classNamestring—
Accessibility #
It exposes dialog semantics, with aria-label, aria-modal, aria-labelledby and aria-hidden kept in step with state.
KeyBehaviour
EscapeCloses or cancels, then returns focus to where it came from.
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.