Morph Surface
InteractiveA container that morphs in place between a compact chip and a rich panel — a single Motion layout element springs its size and corner radius while the two content states cross-fade (AnimatePresence popLayout), with a close affordance, Escape-to-collapse, and managed focus. Controlled or uncontrolled via expanded/defaultExpanded/onExpandedChange. Under prefers-reduced-motion it swaps instantly with no size, radius, or cross-fade animation.
Inspired by Cult UI — Morph / Dock Surface (free, MIT) — independently built, not affiliated. Attribution policy
Installation
npx shadcn@latest add https://parable-three.vercel.app/r/morph-surface.jsonRuns the shadcn CLI — the component source is copied intocomponents/parable/morph-surface.tsx. You own it from there.
Usage
The exact code rendering the preview above.
<div style={{ display: "flex", minHeight: 340, width: "100%", alignItems: "center", justifyContent: "center", padding: 24 }}>
<MorphSurface
accent="#8b5cf6"
panelLabel="Team member"
closeLabel="Collapse profile"
compact={
<>
<span style={{ display: "grid", placeItems: "center", height: 24, width: 24, borderRadius: 999, background: "linear-gradient(135deg,#8b5cf6,#ec4899)", fontSize: 11, fontWeight: 700, color: "#fff" }}>AK</span>
<span style={{ fontWeight: 600 }}>Ada Kessler</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, marginLeft: 2, fontSize: 12, color: "#22d3ee" }}>
<span style={{ height: 6, width: 6, borderRadius: 999, background: "#22d3ee", boxShadow: "0 0 8px #22d3ee" }} />
online
</span>
<ChevronRight size={15} style={{ marginLeft: 2, color: "rgba(255,255,255,0.4)" }} />
</>
}
expandedContent={
<div style={{ width: 268 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ display: "grid", placeItems: "center", height: 44, width: 44, borderRadius: 999, background: "linear-gradient(135deg,#8b5cf6,#ec4899)", fontWeight: 700, color: "#fff" }}>AK</span>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Ada Kessler</div>
<div style={{ fontSize: 12.5, color: "rgba(255,255,255,0.55)" }}>Staff Engineer · Platform</div>
</div>
</div>
<p style={{ margin: "12px 0 0", fontSize: 13, lineHeight: 1.55, color: "rgba(255,255,255,0.72)" }}>
Owns the ingestion pipeline and on-call tooling. Usually replies within a few hours.
</p>
<div style={{ marginTop: 14, display: "flex", gap: 8 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: 10, border: "1px solid rgba(255,255,255,0.12)", background: "rgba(255,255,255,0.03)", padding: "7px 11px", fontSize: 12.5, fontWeight: 500 }}>
<MessageCircle size={14} style={{ color: "#8b5cf6" }} /> Message
</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: 10, border: "1px solid rgba(255,255,255,0.12)", background: "rgba(255,255,255,0.03)", padding: "7px 11px", fontSize: 12.5, fontWeight: 500 }}>
<CalendarDays size={14} style={{ color: "#f5a623" }} /> Schedule
</span>
</div>
</div>
}
/>
</div>Props
MorphSurfaceProps extends PassthroughDivProps
| Prop | Type | Default | Description |
|---|---|---|---|
| compact* | React.ReactNode | — | The collapsed "chip" content. Clicking it expands the surface. |
| expandedContent* | React.ReactNode | — | The rich "panel" content shown when expanded. |
| expanded | boolean | — | Controlled open state. Omit to let the component manage its own. |
| defaultExpanded | boolean | false | Initial open state when uncontrolled. Defaults to `false`. |
| onExpandedChange | (expanded: boolean) => void | — | Fires whenever the open state should change (both modes). |
| accent | string | "#8b5cf6" | Accent for the focus ring and the expanded-panel sheen. Defaults to Parable violet `#8b5cf6` (mirrors the site's `--pb-violet` token). |
| panelLabel | string | "Details" | Accessible name for the expanded panel group. Defaults to "Details". |
| closeLabel | string | "Collapse" | Accessible label for the collapse control. Defaults to "Collapse". |
npm dependencies
- motion
- lucide-react