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
npm i framer-motion
2. Files
"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. 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);
}
/*
* 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
import { Popover } from "./Popover";
import "./Popover.css";
export default function Demo() {
return <Popover />;
}