Popover

A button-anchored floating panel for rich content — forms, settings, previews, and pickers. Unlike a Tooltip, a Popover is interactive and is triggered by click, not hover.

Preview

When to use

ComponentTriggerContentBlocking
PopoverClickRich interactive contentNo
TooltipHover / focusShort supplementary labelNo
Dropdown MenuClickList of commands/actionsNo
ModalExplicit actionCritical task requiring full attentionYes

Anatomy

ElementSpec
Panel surfaceblur(24px) saturate(160%), --surface at 94% opacity, --shadow-sheet, 1px border
Specular top edge1px gradient line: transparent → white/15% → transparent
Header13px semibold title, close button (X), border-bottom: --border-subtle, px-4 py-3
Bodypx-4 py-3, content area — forms, toggles, pickers, text
Footer (optional)border-top: --border-subtle, px-4 py-3, flex justify-end, action buttons
Width240–400px depending on content. Fixed width, not full-screen on desktop
Offset from trigger8px gap (mt-2 / mb-2)
Border radius--radius-lg (14px)

Implementation

Popover.tsx
tsx
"use client";

import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib";

interface PopoverProps {
  content: React.ReactNode;
  trigger: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
  width?: number | string;
  onOpenChange?: (open: boolean) => void;
}

export function Popover({
  content,
  trigger,
  side = "bottom",
  align = "start",
  width = 320,
  onOpenChange,
}: PopoverProps) {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef   = useRef<HTMLDivElement>(null);

  const close = useCallback(() => {
    setOpen(false);
    onOpenChange?.(false);
  }, [onOpenChange]);

  const toggle = () => {
    const next = !open;
    setOpen(next);
    onOpenChange?.(next);
  };

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent | KeyboardEvent) => {
      if (e instanceof KeyboardEvent && e.key !== "Escape") return;
      if (e instanceof MouseEvent) {
        if (triggerRef.current?.contains(e.target as Node)) return;
        if (panelRef.current?.contains(e.target as Node)) return;
      }
      close();
    };
    document.addEventListener("mousedown", handler);
    document.addEventListener("keydown", handler);
    return () => {
      document.removeEventListener("mousedown", handler);
      document.removeEventListener("keydown", handler);
    };
  }, [open, close]);

  const verticalClass =
    side === "top"   ? "bottom-full mb-2" :
    side === "left"  ? "right-full mr-2 top-0" :
    side === "right" ? "left-full ml-2 top-0"  :
                       "top-full mt-2";

  const alignClass =
    align === "end"    ? "right-0" :
    align === "center" ? "left-1/2 -translate-x-1/2" :
                         "left-0";

  return (
    <div className="relative inline-flex">
      <button ref={triggerRef} onClick={toggle} aria-expanded={open} aria-haspopup="dialog">
        {trigger}
      </button>

      {open && (
        <div
          ref={panelRef}
          role="dialog"
          aria-modal="false"
          className={cn("absolute z-50 rounded-[var(--radius-lg)] border border-[rgb(var(--border))] overflow-hidden", verticalClass, alignClass)}
          style={{
            width,
            background: "rgb(var(--surface) / 0.94)",
            backdropFilter: "blur(24px) saturate(160%)",
            WebkitBackdropFilter: "blur(24px) saturate(160%)",
            boxShadow: "var(--shadow-sheet), inset 0 1px 0 rgba(255,255,255,0.08)",
          }}
        >
          <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent pointer-events-none" />
          {content}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
contentReactNodeRich content rendered inside the popover panel. Can include any React node — forms, settings, previews.
triggerReactNodeThe trigger element. Rendered as a button that toggles the popover on click.
side"top" | "bottom" | "left" | "right""bottom"Preferred placement relative to the trigger. Flips automatically if it would overflow the viewport.
align"start" | "center" | "end""start"Horizontal alignment of the panel relative to the trigger.
widthnumber | string320Panel width in pixels, or any CSS width value.
onOpenChange(open: boolean) => voidCalled when the popover opens or closes.

Accessibility

  • Set aria-haspopup='dialog' and aria-expanded on the trigger. Update aria-expanded when the panel opens and closes.
  • Set role='dialog' and aria-modal='false' on the panel. A popover is non-modal — the user can still interact with the page behind it.
  • Escape always closes the panel and returns focus to the trigger button.
  • Move focus into the panel on open — focus the first interactive element (input, toggle, close button).
  • Trap focus inside the panel only when aria-modal='true' (full modal behavior). For non-modal popovers, allow Tab to leave the panel naturally.
  • Provide a visible close button (×) for users who cannot press Escape.
  • Never put content required to complete a task inside a popover — use a Modal for blocking, critical flows.