Popover morph

Free · MIT

A small button such as "Add note" morphs into a floating form panel, its label sliding into the header, and shrinks back into the button on save, Esc or an outside click.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/popover-morph.json

The file lands in components/goodui/popover-morph.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/popover-morph works too.

Usage

import * as React from "react"
import { StickyNote } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { PopoverMorph } from "@/components/goodui/popover-morph"

export function AddNote({ onAdd }: { onAdd: (text: string) => void }) {
  const [draft, setDraft] = React.useState("")

  return (
    <PopoverMorph label={<><StickyNote /> Add note</>} title={<><StickyNote /> Note for Sofia</>}>
      {({ close }) => (
        <form
          onSubmit={(e) => {
            e.preventDefault()
            if (!draft.trim()) return
            onAdd(draft.trim())
            setDraft("")
            close()
          }}
          className="space-y-2.5"
        >
          <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Prefers a cooler tone than last time." rows={3} />
          <Button type="submit" size="sm" className="w-full">
            Save note
          </Button>
        </form>
      )}
    </PopoverMorph>
  )
}

When to use

Use it for
  • Quick input tied to one button: add a note to a booking, set a reminder, apply a discount code.
  • Toolbars and record headers where a full dialog would interrupt the page.
  • Small settings that should close as soon as the user clicks elsewhere.
Reach for something else
  • A list of actions or options: use dropdown-stagger, which has menu roles and arrow-key navigation.
  • Read-only hints on hover: use tooltip-animated.
  • Larger forms or anything that must not be lost on an outside click: use drawer-spring or morphing-dialog, which are modal.
If you needUse
Trigger near the top of the pageside="bottom" (default): the panel grows down from the trigger
Trigger near the bottom (composer, footer bar)side="top": the panel grows up
Trigger at the right end of a toolbaralign="end"
Narrow panel for one fieldwidth={280}

Examples

Discount code at the end of a toolbar

<PopoverMorph
  label={<><TicketPercent /> {discount ?? "Discount"}</>}
  title={<><TicketPercent /> Apply a code</>}
  align="end"
  width={280}
>
  {({ close }) => (
    <form onSubmit={(e) => { e.preventDefault(); apply(code); close() }} className="flex gap-2">
      <Input data-autofocus value={code} onChange={(e) => setCode(e.target.value)} placeholder="WELCOME10" />
      <Button type="submit">Apply</Button>
    </form>
  )}
</PopoverMorph>

Controlled, opening upward from a footer bar

<PopoverMorph side="top" open={open} onOpenChange={setOpen} label={<><BellRing /> Remind</>}>
  <p className="text-sm text-muted-foreground">We will text the client 1 hour before.</p>
</PopoverMorph>

API reference

PropTypeDefault
label*
Trigger content; it slides into the panel header when the popover opens.
React.ReactNode—
children*
Panel body. Pass a function to get close(), e.g. to close after a form submits.
React.ReactNode | ((api: { close: () => void }) => React.ReactNode)—
title
Header text inside the panel. Defaults to the label.
React.ReactNode—
open
Controlled open state. Pair with onOpenChange.
boolean—
defaultOpen
Initial open state when uncontrolled.
booleanfalse
onOpenChange
Called with the next state on trigger click, close(), Esc, an outside click or tabbing out.
(open: boolean) => void—
side
The panel grows down from the trigger, or up for "top".
"bottom" | "top""bottom"
align
Preferred edge (logical: start is the left edge in LTR, the right edge in RTL); it flips when the panel would leave the viewport.
"start" | "end""start"
width
Panel width in px (capped to the viewport).
number320
triggerClassName
Classes for the trigger button.
string—
panelClassName
Classes for the floating panel.
string—
labels
Text for built-in controls and screen readers; pass translations here.
Partial<PopoverMorphLabels>—

Also accepts every <div> attribute.

Accessibility

Enter / SpaceOn the trigger (a native <button>): opens the panel and focuses the first [data-autofocus] or focusable element in the body.
EscCloses the panel and returns focus to the trigger.
TabMoves through the panel; tabbing out of it closes the panel.
  • The trigger has aria-haspopup="dialog", aria-expanded, and aria-controls pointing at the panel while it is open.
  • The panel has role="dialog" and is named by its header (title, or label when no title is given). It is non-modal: no focus trap and no scroll lock.
  • The close button is labelled labels.close (default "Close").
  • An outside click closes the panel without moving focus; Esc and the close button return focus to the trigger.

Motion

  • The trigger and the panel share one surface (layoutId), so the button grows into the panel on a spring (bounce 0.1, 0.38 s) and shrinks back on close. The label slides into the panel header.
  • The close button pops in from scale 0.6; the body fades up 4px after a 60 ms delay (200 ms) and fades out in 60 ms.
  • Reduced motion: the morph and the body appear without movement (duration 0).

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Pass the trigger content through label (icon + text), not as children; children is the panel body.
  • Use the function child ({ close }) => … when a form should close after submitting; close() returns focus to the trigger.
  • The panel is absolutely positioned inside an inline-block wrapper and covers the trigger while open. Give the parent room and do not clip it with overflow: hidden.
  • align is logical (start = left in LTR, right in RTL) and only flips between start and end when the panel would leave the viewport; there is no vertical flip, so choose side yourself.
  • The body unmounts whenever the panel closes (outside click, focus leaving), so keep draft state in the parent, as in the example, and keep long forms in a drawer or dialog.

FAQ

Why does the panel cover the button?

It grows out of the button: the panel starts at the trigger's edge, so the label can slide straight into the header.

Do I have to use the render function?

No. Plain children work too; the panel then closes through its close button, Esc, an outside click, or your own open state.

Related