Teseria
All elements

Popover

Press the trigger and a card pops out of a blur from its side. Focus moves in; Escape or a click outside sends it back and returns focus to the trigger.

Code

1. Install

Terminal
npm i framer-motion

2. Files

Popover.tsx224 lines
"use client";
// Popover: press the trigger and a card pops out of a blur from its side. Focus moves in; Escape or a click outside sends it back
// and returns focus to the trigger.
import { cloneElement, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type MouseEvent, type ReactElement, type ReactNode, type RefObject } from "react";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";

export type PopoverCue = "unfold" | "fold";
export type PopoverSide = "top" | "bottom" | "left" | "right";
export type PopoverAlign = "center" | "start" | "end";

export type PopoverProps = {
  /** The element that opens the card (one focusable element). Defaults to a line button labelled `triggerLabel`. */
  trigger?: ReactElement;
  /** Label of the default trigger. */
  triggerLabel?: string;
  /** Heading of the default card. */
  title?: string;
  /** Text under the heading of the default card. */
  body?: string;
  /** Label of the default card's ghost button. */
  cancelLabel?: string;
  /** Label of the default card's solid button. */
  confirmLabel?: string;
  /** Called when the default card's solid button is pressed; the card closes after. */
  onConfirm?: () => void;
  /** Your own card content, replacing the default one. A function receives `close`. */
  children?: ReactNode | ((close: () => void) => ReactNode);
  /** Which side of the trigger it opens on (flips if there is no room). */
  side?: PopoverSide;
  /** How the card lines up with the trigger. */
  align?: PopoverAlign;
  /** Open state (controlled). */
  open?: boolean;
  /** Starts open (uncontrolled), without taking focus. */
  defaultOpen?: boolean;
  /** Called with the next open state. */
  onOpenChange?: (open: boolean) => void;
  /** Card width in px. */
  width?: number;
  /** Sound hook: "unfold" on open, "fold" on close. */
  onSound?: (cue: PopoverCue) => void;
  className?: string;
  style?: CSSProperties;
};

const SPRING = { type: "spring", stiffness: 500, damping: 26, mass: 0.7 } as const;
const OPPOSITE: Record<PopoverSide, PopoverSide> = { top: "bottom", bottom: "top", left: "right", right: "left" };
// where the float slides in from (px) and the corner it grows out of
const ENTER: Record<PopoverSide, { x: number; y: number; origin: string }> = {
  top: { x: 0, y: 6, origin: "50% 100%" },
  bottom: { x: 0, y: -6, origin: "50% 0%" },
  left: { x: 6, y: 0, origin: "100% 50%" },
  right: { x: -6, y: 0, origin: "0% 50%" },
};
const VIEWPORT_MARGIN = 8;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

/** Fixed position next to the anchor on `side`, or on the opposite side when there is no room, kept inside the viewport. */
function place(anchor: DOMRect, width: number, height: number, side: PopoverSide, align: PopoverAlign, gap: number) {
  const middleX = anchor.left + anchor.width / 2 - width / 2;
  const middleY = anchor.top + anchor.height / 2 - height / 2;
  const spot = (s: PopoverSide) => {
    if (s === "top") return { x: align === "start" ? anchor.left : align === "end" ? anchor.right - width : middleX, y: anchor.top - gap - height, room: anchor.top - gap - height - VIEWPORT_MARGIN };
    if (s === "bottom") return { x: align === "start" ? anchor.left : align === "end" ? anchor.right - width : middleX, y: anchor.bottom + gap, room: innerHeight - VIEWPORT_MARGIN - (anchor.bottom + gap + height) };
    if (s === "left") return { x: anchor.left - gap - width, y: align === "start" ? anchor.top : align === "end" ? anchor.bottom - height : middleY, room: anchor.left - gap - width - VIEWPORT_MARGIN };
    return { x: anchor.right + gap, y: align === "start" ? anchor.top : align === "end" ? anchor.bottom - height : middleY, room: innerWidth - VIEWPORT_MARGIN - (anchor.right + gap + width) };
  };
  const chosen = spot(side).room < 0 && spot(OPPOSITE[side]).room >= 0 ? OPPOSITE[side] : side;
  const { x, y } = spot(chosen);
  return { x: clamp(x, VIEWPORT_MARGIN, innerWidth - width - VIEWPORT_MARGIN), y: clamp(y, VIEWPORT_MARGIN, innerHeight - height - VIEWPORT_MARGIN), side: chosen };
}

type BoxProps = { anchor: RefObject<HTMLElement | null>; rest: boolean; side: PopoverSide; align: PopoverAlign; id: string; name: string; onMount: (el: HTMLDivElement) => void; children: ReactNode };

/** The floating card, in the top layer (popover="manual") so no ancestor clips it; a resting card stays in the page. */
function Box({ anchor, rest, side, align, id, name, onMount, children }: BoxProps) {
  const el = useRef<HTMLDivElement>(null);
  const inline = useRef(rest).current;
  const [pos, setPos] = useState<{ x: number; y: number; side: PopoverSide } | null>(null);
  useLayoutEffect(() => {
    if (!inline) el.current?.showPopover?.(); // top layer where supported, otherwise it stays a fixed element
    const update = () => {
      const f = el.current;
      const a = anchor.current?.getBoundingClientRect();
      const o = inline && f?.offsetParent?.getBoundingClientRect();
      if (a && f) setPos(place(o ? new DOMRect(a.x - o.x, a.y - o.y, a.width, a.height) : a, f.offsetWidth, f.offsetHeight, side, align, 8));
    };
    update();
    if (el.current) onMount(el.current);
    window.addEventListener("scroll", update, true);
    window.addEventListener("resize", update);
    return () => {
      window.removeEventListener("scroll", update, true);
      window.removeEventListener("resize", update);
    };
  }, [side, align]);
  const s = pos?.side ?? side;
  return (
    <motion.div
      ref={el}
      role="dialog"
      id={id}
      aria-label={name}
      popover={inline ? undefined : "manual"}
      className="tpop-float"
      style={{ position: inline ? "absolute" : "fixed", left: pos?.x ?? 0, top: pos?.y ?? 0, zIndex: 50, visibility: pos ? "visible" : "hidden", transformOrigin: ENTER[s].origin }}
      initial={{ opacity: 0, scale: 0.9, filter: "blur(6px)", x: ENTER[s].x, y: ENTER[s].y }}
      animate={{ opacity: 1, scale: 1, filter: "blur(0px)", x: 0, y: 0, transitionEnd: { filter: "none" } }}
      exit={{ opacity: 0, scale: 0.94, filter: "blur(5px)", x: ENTER[s].x, y: ENTER[s].y, transition: { duration: 0.14, ease: [0.4, 0, 1, 1] } }}
      transition={{ ...SPRING, opacity: { duration: 0.16 }, filter: { duration: 0.2 } }}
    >
      {children}
    </motion.div>
  );
}

export function Popover({
  trigger,
  triggerLabel = "Invite",
  title = "Invite a teammate",
  body = "They get edit access to this project only.",
  cancelLabel = "Cancel",
  confirmLabel = "Send invite",
  onConfirm,
  children,
  side = "bottom",
  align = "center",
  open: ctrl,
  defaultOpen = false,
  onOpenChange,
  width = 240,
  onSound,
  className,
  style,
}: PopoverProps) {
  const id = useId();
  const [inner, setInner] = useState(defaultOpen);
  const asked = useRef(!defaultOpen); // false while resting (open from defaultOpen, untouched)
  const open = ctrl ?? inner;
  const anchor = useRef<HTMLSpanElement>(null);
  const card = useRef<HTMLDivElement | null>(null);
  const changeOpen = (next: boolean) => {
    if (next === open) return;
    asked.current = true;
    onSound?.(next ? "unfold" : "fold");
    if (ctrl === undefined) setInner(next);
    onOpenChange?.(next);
    if (!next && card.current?.contains(document.activeElement)) (anchor.current?.querySelector<HTMLElement>("button, [tabindex]") ?? anchor.current)?.focus();
  };
  useEffect(() => {
    if (!open) return;
    const inside = (n: Node | null) => !!n && (anchor.current?.contains(n) || card.current?.contains(n));
    const awake = () => asked.current || inside(document.activeElement);
    const down = (e: PointerEvent) => (inside(e.target as Node) ? (asked.current = true) : awake() && changeOpen(false));
    const onEscape = (e: KeyboardEvent) => e.key === "Escape" && awake() && changeOpen(false);
    const armOutsideClick = window.setTimeout(() => document.addEventListener("pointerdown", down), 0);
    window.addEventListener("keydown", onEscape);
    return () => {
      window.clearTimeout(armOutsideClick);
      document.removeEventListener("pointerdown", down);
      window.removeEventListener("keydown", onEscape);
    };
  }, [open]);

  const close = () => changeOpen(false);
  const trig = trigger ?? (
    <button type="button" className="tpop-btn" data-variant="line">
      {triggerLabel}
    </button>
  );
  return (
    <>
      <span ref={anchor} className={className ? `tpop-root ${className}` : "tpop-root"} style={style}>
        {cloneElement(trig as ReactElement<HTMLAttributes<HTMLElement>>, {
          "aria-expanded": open,
          "aria-haspopup": "dialog",
          "aria-controls": open ? id : undefined,
          onClick: (e: MouseEvent<HTMLElement>) => {
            (trig.props as HTMLAttributes<HTMLElement>).onClick?.(e);
            changeOpen(!open);
          },
        })}
      </span>
      <MotionConfig reducedMotion="user">
        <AnimatePresence>
          {open && (
            <Box
              key="f"
              anchor={anchor}
              rest={!asked.current}
              side={side}
              align={align}
              id={id}
              name={title}
              onMount={(el) => {
                card.current = el;
                if (asked.current) window.setTimeout(() => el.querySelector<HTMLElement>("input, button, [tabindex]")?.focus({ preventScroll: true }), 30);
              }}
            >
              <div className="tpop-card" style={{ width }}>
                {children !== undefined ? (
                  typeof children === "function" ? children(close) : children
                ) : (
                  <>
                    <p className="tpop-title">{title}</p>
                    <p className="tpop-body">{body}</p>
                    <div className="tpop-row">
                      <button type="button" className="tpop-btn" data-variant="ghost" data-size="sm" onClick={close}>
                        {cancelLabel}
                      </button>
                      <button type="button" className="tpop-btn" data-size="sm" onClick={() => (onConfirm?.(), close())}>
                        {confirmLabel}
                      </button>
                    </div>
                  </>
                )}
              </div>
            </Box>
          )}
        </AnimatePresence>
      </MotionConfig>
    </>
  );
}
Popover.css104 lines
/* Popover. Prefix tpop-. The card lives in the top layer, so it carries its own basics. */
.tpop-root, .tpop-root *, .tpop-float, .tpop-float * { box-sizing: border-box; }
.tpop-root, .tpop-float { font-family: var(--ts-font, system-ui, sans-serif); }
:where(.tpop-root) button, :where(.tpop-float) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; }
:where(.tpop-root) :focus-visible, :where(.tpop-float) :focus-visible { outline: 2px solid var(--ts-focus); outline-offset: 2px; }

.tpop-root {
  display: inline-flex;
}
/* top-layer element: undo the browser's popover defaults */
.tpop-float {
  position: fixed;
  inset: auto;
  margin: 0;
  padding: 0;
  border: 0;
  overflow: visible;
  background: none;
  color: inherit;
}
.tpop-card {
  padding: 14px;
  border-radius: var(--ts-r-pop);
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  font-size: 13.5px;
  letter-spacing: -0.01em;
  outline: 0;
}
.tpop-title {
  font-weight: 500;
  margin: 0 0 4px;
  font-size: 14px;
}
.tpop-body {
  color: var(--ts-fg-tertiary);
  margin: 0 0 12px;
  line-height: 1.4;
}
.tpop-row {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}

/* the small button set the default trigger and card use */
.tpop-btn {
  --tpop-h: 40px;
  --tpop-px: 16px;
  --tpop-fs: 14px;
  height: var(--tpop-h);
  padding: 0 var(--tpop-px);
  border: 0;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-size: var(--tpop-fs);
  font-weight: 500;
  letter-spacing: -0.01em;
  white-space: nowrap;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  background: var(--ts-fg);
  color: var(--ts-raised);
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), color 0.16s cubic-bezier(.22, 1, .36, 1), opacity 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tpop-btn[data-size="sm"] {
  --tpop-h: 32px;
  --tpop-px: 12px;
  --tpop-fs: 13px;
  gap: 6px;
}
.tpop-btn:active {
  scale: 0.96;
}
.tpop-btn:focus-visible {
  outline: 0;
  box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.tpop-btn:not([data-variant]):hover {
  opacity: 0.92;
}
.tpop-btn[data-variant="line"] {
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
}
.tpop-btn[data-variant="line"]:hover {
  background: rgba(var(--ts-fg-rgb), 0.05);
}
.tpop-btn[data-variant="line"]:focus-visible {
  box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.tpop-btn[data-variant="ghost"] {
  background: transparent;
  color: color-mix(in srgb, var(--ts-fg) 75%, var(--ts-raised));
}
.tpop-btn[data-variant="ghost"]:hover {
  background: rgba(var(--ts-fg-rgb), 0.06);
  color: var(--ts-fg);
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Popover } from "./Popover";
import "./Popover.css";

export default function Demo() {
  return <Popover />;
}