yugodesign

Overlay·05.9

Morphing Popover

The button becomes the panel, and goes back.

No note yet

morphing-popover

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/morphing-popover.json

Usage

stats.tsx
"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

components/yugo/morphing-popover.tsx
"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

trigger
ReactNode

What the button says while it is still a button. It fades before the surface finishes growing, so nothing ghosts through the panel.

children
ReactNode

The panel's contents. They arrive after the surface, not with it.

label
string

The dialog's accessible name, and the name of the visually hidden close control at the end of the panel.

open
boolean

Controlled state. Supplying it makes the parent the source of truth.

defaultOpenfalse
boolean

Uncontrolled starting state.

onOpenChange
(open: boolean) => void

Fires 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""
string

Appended last to the trigger button.

className""
string

Appended last to the panel.