Skip to content
Craft

Craft · 試作

Origin-aware popover

The panel grows out of the button that opened it — its transform-origin points at the trigger's centre, so it unfolds from the right spot every time.

  • GSAP
  • transform-origin
  • A11y

Live demo

Live · try it with mouse, touch, or keyboard

How it’s built

  • On open I measure the trigger, clamp the panel within the stage, and set transform-origin to the trigger's centre in px.
  • Mouse opens with a quick scale(0.96)+fade from that origin; keyboard opens instantly — animating keyboard-driven UI is disorienting.
  • Positioning + the tween run in a GSAP layout effect, so the panel never flashes unpositioned.
  • Escape closes and restores focus to the trigger; an outside pointerdown closes without stealing focus.

Accessibility

  • Keyboard opens render instantly — no disorienting motion on UI you summoned.
  • Escape closes and restores focus to the trigger.

Source

The exact component, read straight from the repo at build. Requires gsap and @gsap/react.

OriginPopoverDemo.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { Copy, Link2, Share2 } from "lucide-react";
import { prefersReducedMotion } from "@/lib/motion";

gsap.registerPlugin(useGSAP);

const TRIGGERS = ["Account", "Filters", "Share"];

/**
 * Popover that grows out of whichever trigger opened it: its transform-origin is
 * set to the trigger's centre, so the scale-in reads as the panel unfolding from
 * the button. Opened by mouse → a quick scale(0.96)+fade; opened by keyboard →
 * appears instantly (animating keyboard-driven UI is disorienting). Escape and
 * outside-click close it; Escape restores focus to the trigger.
 */
export function OriginPopoverDemo() {
  const stageRef = useRef<HTMLDivElement>(null);
  const popRef = useRef<HTMLDivElement>(null);
  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  // Whether the current open was triggered from the keyboard (detail === 0).
  const viaKeyboard = useRef(false);

  // Position the panel under its trigger, point the transform-origin at the
  // trigger centre, then play it in. Runs in a layout effect → no flash.
  useGSAP(
    () => {
      if (openIndex == null) return;
      const pop = popRef.current;
      const stage = stageRef.current;
      const trigger = btnRefs.current[openIndex];
      if (!pop || !stage || !trigger) return;

      const stageBox = stage.getBoundingClientRect();
      const tb = trigger.getBoundingClientRect();
      const triggerCenter = tb.left - stageBox.left + tb.width / 2;
      const popW = pop.offsetWidth;
      const left = Math.max(
        8,
        Math.min(triggerCenter - popW / 2, stage.clientWidth - popW - 8),
      );
      const top = tb.bottom - stageBox.top + 10;
      gsap.set(pop, { left, top });
      pop.style.transformOrigin = `${triggerCenter - left}px top`;

      const instant = viaKeyboard.current || prefersReducedMotion();
      gsap.fromTo(
        pop,
        { autoAlpha: 0, scale: instant ? 1 : 0.96 },
        { autoAlpha: 1, scale: 1, duration: instant ? 0 : 0.22, ease: "power2.out" },
      );
      pop.querySelector<HTMLElement>("a,button")?.focus();
    },
    { dependencies: [openIndex], scope: stageRef },
  );

  const close = useCallback(
    (restoreFocus: boolean) => {
      const pop = popRef.current;
      const idx = openIndex;
      const finish = () => {
        setOpenIndex(null);
        if (restoreFocus && idx != null) btnRefs.current[idx]?.focus();
      };
      if (!pop || prefersReducedMotion()) {
        finish();
        return;
      }
      gsap.to(pop, {
        autoAlpha: 0,
        scale: 0.96,
        duration: 0.16,
        ease: "power2.out",
        onComplete: finish,
      });
    },
    [openIndex],
  );

  // Close on any pointerdown outside the panel + its trigger (no focus restore).
  useEffect(() => {
    if (openIndex == null) return;
    const onDown = (e: PointerEvent) => {
      const pop = popRef.current;
      const trig = btnRefs.current[openIndex];
      const target = e.target as Node;
      if (pop && !pop.contains(target) && trig && !trig.contains(target)) {
        close(false);
      }
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [openIndex, close]);

  return (
    <div
      ref={stageRef}
      onKeyDown={(e) => {
        if (e.key === "Escape" && openIndex != null) close(true);
      }}
      className="relative flex h-full w-full items-start justify-center p-6"
    >
      <div className="mt-2 flex flex-wrap items-center justify-center gap-2">
        {TRIGGERS.map((label, i) => {
          const isOpen = openIndex === i;
          return (
            <button
              key={label}
              ref={(el) => {
                btnRefs.current[i] = el;
              }}
              type="button"
              aria-haspopup="dialog"
              aria-expanded={isOpen}
              onClick={(e) => {
                if (openIndex === i) {
                  close(true);
                } else {
                  viaKeyboard.current = e.detail === 0;
                  setOpenIndex(i);
                }
              }}
              className={`rounded-lg border px-4 py-2 text-sm font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-accent ${
                isOpen
                  ? "border-accent/60 bg-accent/15 text-foreground"
                  : "border-border bg-surface text-foreground/75 hover:border-border-strong hover:text-foreground"
              }`}
            >
              {label}
            </button>
          );
        })}
      </div>

      {openIndex != null && (
        <div
          ref={popRef}
          role="dialog"
          aria-label={`${TRIGGERS[openIndex]} options`}
          style={{ position: "absolute", opacity: 0 }}
          className="z-10 w-56 rounded-xl border border-border-strong bg-elevated/95 p-1.5 shadow-[0_24px_60px_-24px_rgb(0_0_0_/_0.85)] backdrop-blur-xl"
        >
          <p className="px-3 pb-1.5 pt-2 text-xs font-medium uppercase tracking-[0.18em] text-foreground/55">
            {TRIGGERS[openIndex]}
          </p>
          {[
            { icon: Link2, label: "Copy link" },
            { icon: Copy, label: "Duplicate" },
            { icon: Share2, label: "Share…" },
          ].map(({ icon: Icon, label }) => (
            <button
              key={label}
              type="button"
              onClick={() => close(true)}
              className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-foreground/80 outline-none transition-colors hover:bg-accent/15 hover:text-foreground focus-visible:bg-accent/15 focus-visible:text-foreground"
            >
              <Icon className="h-4 w-4 text-foreground/55" />
              {label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Usage

usage.tsx
import { OriginPopoverDemo } from "@/components/craft/OriginPopoverDemo";

<OriginPopoverDemo />

Why these choices? The timing, easing, and reduced-motion rules behind every demo live in the motion skill file.

Read it