Skip to content
peetzweg/ui

MorphSurface

A surface that springs between two sizes — a small trigger and a larger panel — morphing its width, height, and corner radius in one motion. The trigger stays mounted and bottom-anchored while the panel cross-fades in above it, so the box appears to grow out of the trigger rather than swap for a different element.

Give an element on each side the same motion layoutId and it travels between states as the surface morphs — here the message icon flies from the trigger into the form header. Click the pill to expand; it dismisses on click-outside or Escape.

Installation

pnpm dlx shadcn@latest add peetzweg/ui/morph-surface

Pulls the use-click-outside hook (a registryDependency) plus motion.

Usage

MorphSurface is headless: it owns the morph, the open/close state, and dismissal only. collapsed and expanded are your content — pass nodes, or render functions that receive the live { open, expand, collapse, setOpen } state.

import { MorphSurface } from "@/components/ui/morph-surface"
 
<MorphSurface
  className="border border-border bg-popover shadow-lg"
  collapsed={({ expand }) => (
    <div className="flex h-[44px] items-center gap-2 px-3">
      <span className="size-5 rounded-full bg-primary" />
      <button onClick={expand}>Feedback</button>
    </div>
  )}
  expanded={
    <form className="h-full p-2">
      <textarea className="size-full" placeholder="What's on your mind?" />
    </form>
  }
/>

Geometry is prop-driven — the surface needs fixed collapsed/expanded boxes to spring between — but everything inside is yours. className / style land on the morphing surface itself (give it the background, border, and shadow).

The travelling element

The signature move is a single element that appears to migrate between the trigger and the panel. There's no special prop for it: render an element on each side with the same motion layoutId, and motion tweens it from one position (and size) to the other as the surface morphs. While open, hide it on the trigger side (render a same-size placeholder so layout holds) so only one copy is live:

collapsed={({ open, expand }) => (
  <footer className="flex h-[44px] items-center gap-2 px-3">
    {open ? (
      <span className="size-5" />
    ) : (
      <motion.span layoutId="dot" className="size-5 rounded-full bg-primary" />
    )}
    <button onClick={expand}>Feedback</button>
  </footer>
)}
expanded={
  <form className="h-full p-2">
    <motion.span
      layoutId="dot"
      className="absolute left-4 top-4 size-2 rounded-full bg-primary"
    />
    {/* … */}
  </form>
}

Controlled

Drive it from your own state with open + onOpenChange — for syncing focus, running a success animation after submit, or opening from elsewhere. (The demo above is controlled, which is how it focuses the textarea on open and shows the checkmark after submit.)

const [open, setOpen] = React.useState(false)
 
<MorphSurface open={open} onOpenChange={setOpen} collapsed={} expanded={} />

Uncontrolled, pass defaultOpen instead and read changes via onOpenChange.

Another shape: a reaction tray

The feedback form grows vertically into a form. The same primitive grows horizontally into a pill just as well — here a round icon button unfurls into a row of reactions, and the one you pick flies back into the trigger. Two things make it read cleanly: collapsedHeight === expandedHeight, so only the width morphs (a pure horizontal unfurl, no vertical drift), and each reaction's layoutId keys off its id (reaction-like, reaction-love, …), so the travelling element is whichever one you click — a moving target rather than a fixed dot. While open the trigger renders null (the picked icon has migrated into the tray), so there's never a duplicate layoutId on screen.

const [selectedId, setSelectedId] = React.useState<string | null>(null)
 
<MorphSurface
  open={open}
  onOpenChange={setOpen}
  collapsedWidth={48} collapsedHeight={48} collapsedRadius={24}
  expandedWidth={236} expandedHeight={48} expandedRadius={24}
  // A ring rather than a border, so it doesn't eat 2px off the centred box.
  className="bg-popover shadow-lg ring-1 ring-border"
  collapsed={({ open }) =>
    open ? null : (
      <button onClick={() => setOpen(true)} className="flex size-full items-center justify-center">
        {selected
          ? <motion.span layoutId={`reaction-${selected.id}`}><selected.Icon /></motion.span>
          : <Smile />}
      </button>
    )
  }
  expanded={
    <div className="flex h-full items-center justify-center gap-1">
      {REACTIONS.map(({ id, Icon }) => (
        <motion.button
          key={id}
          layoutId={`reaction-${id}`}
          onClick={() => { setSelectedId(id); setOpen(false) }}
        >
          <Icon />
        </motion.button>
      ))}
    </div>
  }
/>

Because the trigger is a fixed 48px square and the tray a wide-but-short pill, the morph reads as a horizontal unfurl — the collapsed/expanded box sizes are the only thing that changes the character of the motion.

Vertical: a score picker

Hold the width constant and morph the height instead and the same pattern unfurls vertically. The surface grows upward from its bottom anchor, so a column stacks above the trigger — here a 1–10 score, where the number you pick flies back down into the trigger. Same recipe as the reaction tray, rotated: constant width, a per-item layoutId, and null in the trigger while open.

const [score, setScore] = React.useState<number | null>(null)
 
<MorphSurface
  open={open}
  onOpenChange={setOpen}
  collapsedWidth={44} collapsedHeight={44} collapsedRadius={22}
  expandedWidth={44} expandedHeight={320} expandedRadius={22}
  className="bg-popover shadow-lg ring-1 ring-border"
  collapsed={({ open }) =>
    open ? null : (
      <button onClick={() => setOpen(true)} className="flex h-11 w-full items-center justify-center">
        {score !== null
          ? <motion.span layoutId={`score-${score}`}>{score}</motion.span>
          : <Star />}
      </button>
    )
  }
  expanded={
    <div className="flex h-full flex-col items-center justify-center">
      {[10,9,8,7,6,5,4,3,2,1].map((n) => (
        <motion.button
          key={n}
          layoutId={`score-${n}`}
          onClick={() => { setScore(n); setOpen(false) }}
        >
          {n}
        </motion.button>
      ))}
    </div>
  }
/>

Props

PropTypeDefaultDescription
collapsedMorphSurfaceContentResting trigger; always mounted, bottom-anchored. Node or (state) => ReactNode.
expandedMorphSurfaceContentOpen panel; mounted + cross-faded in while open, fills the box.
openbooleanControlled open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => voidNotified on expand / collapse.
collapsedWidthnumber | "auto""auto"Collapsed width; "auto" fits the trigger.
collapsedHeightnumber44Collapsed height (px).
expandedWidthnumber360Expanded width; also the reserved footprint (px).
expandedHeightnumber200Expanded height; also the reserved footprint (px).
collapsedRadiusnumber20Corner radius while collapsed (px).
expandedRadiusnumber14Corner radius while expanded (px).
spring{ stiffness?; damping?; mass? }550 / 45 / 0.7Spring for the morph and cross-fade.
closeOnClickOutsidebooleantrueCollapse on pointer/focus outside the surface.
closeOnEscapebooleantrueCollapse on Escape while open.

Plus all div props — className / style land on the morphing surface.