Morphing Popover
A button grows into a panel and folds back into the button.
"use client"
import {
addTransitionType,
createContext,
startTransition,
useContext,
useId,
useLayoutEffect,
useRef,
useState,
ViewTransition,
type ComponentProps,
type ReactNode,
type RefObject,
} from "react"
function animate(update: () => void) {
document.activeViewTransition?.skipTransition()
startTransition(update)
}
const shell = { "popover-close": "vt-move vt-expand vt-quick", default: "vt-move vt-expand" }
const text = { "popover-close": "vt-move vt-text vt-quick", default: "vt-move vt-text" }
type PopoverState = {
id: string
open: boolean
show: () => void
close: () => void
panel: RefObject<HTMLDivElement | null>
trigger: RefObject<HTMLButtonElement | null>
}
const Context = createContext<PopoverState | null>(null)
function usePopover() {
const popover = useContext(Context)
if (!popover) throw new Error("MorphingPopover parts must be used inside <MorphingPopover>")
return popover
}
export function MorphingPopover({
className = "",
children,
}: {
className?: string
children: ReactNode
}) {
const id = useId()
const [open, setOpen] = useState(false)
const panel = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const wasOpen = useRef(false)
const close = () =>
animate(() => {
addTransitionType("popover-close")
setOpen(false)
})
useLayoutEffect(() => {
if (open) {
const field = panel.current?.querySelector<HTMLElement>(
"[autofocus], input, textarea, select",
)
;(field ?? panel.current)?.focus({ preventScroll: true })
} else if (wasOpen.current) trigger.current?.focus({ preventScroll: true })
wasOpen.current = open
}, [open])
useLayoutEffect(() => {
if (!open) return
const onPointerDown = (e: PointerEvent) => {
const r = panel.current?.getBoundingClientRect()
if (!r) return
const inside =
e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
if (!inside) close()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return
document.activeViewTransition?.skipTransition()
setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
document.addEventListener("keydown", onKeyDown)
return () => {
document.removeEventListener("pointerdown", onPointerDown)
document.removeEventListener("keydown", onKeyDown)
}
}, [open])
const state: PopoverState = {
id,
open,
show: () => animate(() => setOpen(true)),
close,
panel,
trigger,
}
return (
<Context value={state}>
<div className={`flex h-64 w-full max-w-sm justify-center text-foreground ${className}`}>
{children}
</div>
</Context>
)
}
function Label({ children }: { children: ReactNode }) {
const { id } = usePopover()
return (
<ViewTransition name={`${id}-label`} share={text}>
<span className="inline-flex w-fit items-center gap-1.5 [&_svg]:size-4">{children}</span>
</ViewTransition>
)
}
export function MorphingPopoverTrigger({
className = "",
children,
}: {
className?: string
children: ReactNode
}) {
const { id, open, show, trigger } = usePopover()
if (open) return null
return (
<ViewTransition key="button" name={`${id}-shell`} share={shell}>
<button
ref={trigger}
type="button"
aria-expanded={false}
onClick={show}
className={`inline-flex h-9 cursor-pointer items-center justify-center rounded-xl bg-card border px-3 text-sm leading-none font-medium text-card-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 ${className}`}
>
<Label>{children}</Label>
</button>
</ViewTransition>
)
}
export function MorphingPopoverContent({
className = "",
children,
...props
}: ComponentProps<"div">) {
const { id, open, panel } = usePopover()
if (!open) return null
return (
<ViewTransition key="panel" name={`${id}-shell`} share={shell}>
<div
ref={panel}
role="dialog"
aria-labelledby={`${id}-title`}
tabIndex={-1}
className={`h-fit w-full rounded-xl bg-card p-3 text-sm text-card-foreground ring-1 ring-foreground/10 outline-none [&[style*=view-transition-name]]:ring-0 ${className}`}
{...props}
>
{children}
</div>
</ViewTransition>
)
}
export function MorphingPopoverTitle({ children }: { children: ReactNode }) {
const { id } = usePopover()
return (
<p id={`${id}-title`} className="flex h-5 items-center px-1 text-sm leading-none font-medium">
<Label>{children}</Label>
</p>
)
}
export function MorphingPopoverClose({
onClick,
type = "button",
...props
}: ComponentProps<"button">) {
const { close } = usePopover()
return (
<button
type={type}
onClick={(e) => {
onClick?.(e)
close()
}}
{...props}
/>
)
}Installation
- 1Copy the
Componenttab intocomponents/morphing-popover.tsx. - 2Paste the
CSStab intoglobals.css, after Tailwind.
How it works
The button and the panel share one name, keyed so React swaps one for the other, which pairs them into one surface that changes shape. vt-expand paints the surface on the group, so its corners never stretch, and crossfades the contents. The label has its own name and glides from the button into the panel's title. It opens at the pace of Post List's card, a surface changing shape like a small modal, and closes faster: a close transition type maps to vt-quick, since dismissing should snap. Escape closes it instantly, since shortcuts shouldn't animate.
- Uses
- name + share, vt-expand, vt-text
- Theme
- --foreground, --card, --card-foreground, --muted, --ring, --radius