Overlay·05.9
Morphing Popover
The button becomes the panel, and goes back.
No note yet
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/morphing-popover.jsonUsage
"use client";
import { useState } from "react";
import { MorphingPopover } from "@/components/yugo/morphing-popover";
export function NoteButton() {
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
return (
<MorphingPopover
label="New note"
open={open}
onOpenChange={setOpen}
align="center"
trigger="New note"
>
<div className="w-[248px]">
<textarea
value={note}
onChange={(event) => setNote(event.currentTarget.value)}
rows={3}
aria-label="Note"
placeholder="What happened?"
className="w-full resize-none bg-transparent text-[13px] outline-none"
/>
<button type="button" onClick={() => setOpen(false)}>
Save
</button>
</div>
</MorphingPopover>
);
}Source
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const LEAVE = [0.4, 0, 1, 1] as const;
const SURFACE = {
type: "spring",
stiffness: 420,
damping: 36,
mass: 0.9,
} as const;
const INSTANT = { duration: 0 } as const;
const TRIGGER_RADIUS = 9;
const PANEL_RADIUS = 11;
const SIDE = { bottom: "top-0", top: "bottom-0" } as const;
const ALIGN = {
start: "left-0",
center: "left-1/2 -translate-x-1/2",
end: "right-0",
} as const;
export type MorphingPopoverSide = keyof typeof SIDE;
export type MorphingPopoverAlign = keyof typeof ALIGN;
export type UseMorphingPopoverOptions = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export type UseMorphingPopoverResult = {
open: boolean;
/** the layoutId the trigger and the panel share, so one becomes the other */
surfaceId: string;
panelId: string;
setOpen: (open: boolean) => void;
toggle: () => void;
close: (returnFocus?: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
panelRef: React.RefObject<HTMLDivElement | null>;
triggerProps: {
ref: React.RefObject<HTMLButtonElement | null>;
type: "button";
"aria-haspopup": "dialog";
"aria-expanded": boolean;
"aria-controls": string | undefined;
onClick: () => void;
};
panelProps: {
ref: React.RefObject<HTMLDivElement | null>;
id: string;
role: "dialog";
tabIndex: -1;
};
};
/**
* The surface is one object that changes size, not two objects that swap.
* Everything here exists to keep that true: one shared id, and a close path
* that always hands focus back to the thing the panel grew out of.
*/
export function useMorphingPopover({
open: controlled,
defaultOpen = false,
onOpenChange,
}: UseMorphingPopoverOptions = {}): UseMorphingPopoverResult {
const base = useId();
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const open = controlled ?? uncontrolled;
const emit = useRef(onOpenChange);
emit.current = onOpenChange;
const setOpen = useCallback(
(next: boolean) => {
if (controlled === undefined) setUncontrolled(next);
emit.current?.(next);
},
[controlled],
);
const close = useCallback(
(returnFocus = false) => {
setOpen(false);
if (returnFocus) triggerRef.current?.focus({ preventScroll: true });
},
[setOpen],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
useEffect(() => {
if (!open) return;
panelRef.current?.focus({ preventScroll: true });
}, [open]);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (
panelRef.current?.contains(target) ||
triggerRef.current?.contains(target)
) {
return;
}
setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
close(true);
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [open, setOpen, close]);
return {
open,
surfaceId: `${base}-surface`,
panelId: `${base}-panel`,
setOpen,
toggle,
close,
triggerRef,
panelRef,
triggerProps: {
ref: triggerRef,
type: "button",
"aria-haspopup": "dialog",
"aria-expanded": open,
"aria-controls": open ? `${base}-panel` : undefined,
onClick: toggle,
},
panelProps: {
ref: panelRef,
id: `${base}-panel`,
role: "dialog",
tabIndex: -1,
},
};
}
export type MorphingPopoverProps = UseMorphingPopoverOptions & {
trigger: React.ReactNode;
children: React.ReactNode;
label: string;
side?: MorphingPopoverSide;
align?: MorphingPopoverAlign;
triggerClassName?: string;
className?: string;
};
export function MorphingPopover({
trigger,
children,
label,
side = "bottom",
align = "start",
triggerClassName = "",
className = "",
...options
}: MorphingPopoverProps) {
const reduced = useReducedMotion();
const { open, surfaceId, setOpen, close, triggerProps, panelProps } =
useMorphingPopover(options);
// A shared-layout animation cannot be handed `duration: 0`; it has to be
// switched off, and the panel falls back to arriving in place. §22
const morph = reduced ? undefined : surfaceId;
return (
<div
className="relative inline-flex"
onBlurCapture={(event) => {
const next = event.relatedTarget as Node | null;
if (!next) return;
if (event.currentTarget.contains(next)) return;
setOpen(false);
}}
>
<motion.button
{...triggerProps}
layoutId={morph}
style={{
// Motion counter-scales a radius only for the properties it owns,
// and it does not parse the shorthand. Four corners, spelled out. §5i
borderTopLeftRadius: TRIGGER_RADIUS,
borderTopRightRadius: TRIGGER_RADIUS,
borderBottomLeftRadius: TRIGGER_RADIUS,
borderBottomRightRadius: TRIGGER_RADIUS,
}}
transition={reduced ? INSTANT : SURFACE}
className={`inline-flex h-9 select-none items-center gap-2 border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none transition-[border-color,box-shadow] duration-150 hover:border-stone-300 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:border-white/20 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${triggerClassName}`}
>
<motion.span
initial={false}
animate={{ opacity: open ? 0 : 1 }}
transition={
reduced ? INSTANT : { duration: open ? 0.1 : 0.2, ease: EASE }
}
className="inline-flex items-center gap-2"
>
{trigger}
</motion.span>
</motion.button>
<AnimatePresence>
{open ? (
// The wrapper does the anchoring so the morphing surface carries no
// transform of its own: a translate on a layout-animated box is a
// measurement motion has to undo every frame.
<div
key="anchor"
className={`absolute z-50 ${SIDE[side]} ${ALIGN[align]}`}
>
<motion.div
{...panelProps}
aria-label={label}
layoutId={morph}
initial={reduced ? { opacity: 0 } : false}
animate={reduced ? { opacity: 1 } : undefined}
exit={
reduced
? { opacity: 0, transition: { duration: 0.1 } }
: { opacity: 0, transition: { duration: 0.13, ease: LEAVE } }
}
style={{
borderTopLeftRadius: PANEL_RADIUS,
borderTopRightRadius: PANEL_RADIUS,
borderBottomLeftRadius: PANEL_RADIUS,
borderBottomRightRadius: PANEL_RADIUS,
}}
transition={reduced ? INSTANT : SURFACE}
className={`w-max overflow-hidden border border-stone-200 bg-white p-3 shadow-[0_18px_40px_-24px_rgba(28,25,23,0.5)] focus-visible:outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_18px_40px_-24px_rgba(0,0,0,0.9)] ${className}`}
>
<motion.div
initial={
reduced ? false : { opacity: 0, filter: "blur(4px)", y: 4 }
}
animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
exit={
reduced
? { opacity: 0 }
: {
opacity: 0,
filter: "blur(2px)",
transition: { duration: 0.1, ease: LEAVE },
}
}
transition={
reduced ? INSTANT : { duration: 0.22, ease: EASE, delay: 0.04 }
}
>
{children}
</motion.div>
<button
type="button"
onClick={() => close(true)}
className="sr-only"
>
Close {label}
</button>
</motion.div>
</div>
) : null}
</AnimatePresence>
</div>
);
}Props
triggerReactNodeWhat the button says while it is still a button. It fades before the surface finishes growing, so nothing ghosts through the panel.
childrenReactNodeThe panel's contents. They arrive after the surface, not with it.
labelstringThe dialog's accessible name, and the name of the visually hidden close control at the end of the panel.
openbooleanControlled state. Supplying it makes the parent the source of truth.
defaultOpenfalsebooleanUncontrolled starting state.
onOpenChange(open: boolean) => voidFires on open and on every route out: outside press, Escape, focus leaving.
side"bottom""bottom" | "top"Which way the surface grows out of the trigger.
align"start""start" | "center" | "end"Which edge of the trigger the panel is anchored to.
triggerClassName""stringAppended last to the trigger button.
className""stringAppended last to the panel.