## Popover — Overlay Knows its origin, flips on collision. Docs: https://www.interior.dev/docs/popover Reference: https://www.interior.dev/reference/popover License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/popover.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx import { useRef, useState } from "react"; import { Popover } from "@/components/interior/popover"; type Member = { id: string; name: string; email: string; role: string }; export function MemberList({ members }: { members: Member[] }) { const list = useRef(null); const [openId, setOpenId] = useState(null); return (
{members.map((member) => (
{member.name} setOpenId(open ? member.id : null)} trigger={member.role} >

{member.name}

{member.email}

))}
); } ``` ### Props - `trigger` (`React.ReactNode`) Content of the button that owns the popover. The button is the anchor every measurement is taken from. - `children` (`React.ReactNode`) Panel content. It is placed in a scroll box that is capped to the room actually available. - `label` (`string`) Accessible name for the dialog. Read once when the panel takes focus. - `open` (`boolean`) — default: `—` Controlled state. Omit it to let the component own its own open state. - `defaultOpen` (`boolean`) — default: `false` Initial state when uncontrolled. - `onOpenChange` (`(open: boolean) => void`) — default: `—` Fires for every close path: trigger, Escape, outside pointer, focus leaving the panel. - `side` (`"top" | "right" | "bottom" | "left"`) — default: `"bottom"` The side asked for. Honoured when it fits, flipped to its opposite when the opposite has more room. - `align` (`"start" | "center" | "end"`) — default: `"center"` Cross-axis alignment against the trigger, before collision clamping. - `offset` (`number`) — default: `10` Gap in px between trigger and panel, counted as part of the room a side needs. - `padding` (`number`) — default: `8` Minimum px kept between the panel and the edge of the collision boundary. - `arrowSize` (`number`) — default: `9` Edge length of the arrow square. Its centre sits on the panel border and tracks the trigger. - `boundary` (`React.RefObject`) — default: `viewport` Element the panel must stay inside. Intersected with the viewport, so both are respected. - `triggerClassName` (`string`) — default: `""` Appended last to the trigger button. - `className` (`string`) — default: `""` Appended last to the panel. ### Behavior notes - Placement is measured, never assumed: the requested side is kept only when the panel actually fits there, and flips to its opposite when the opposite has more room, so a trigger near an edge cannot open a panel that runs off the screen. - The cross axis is clamped inside the boundary while the arrow is re-solved against the trigger's centre, so a panel that had to slide back into view still points at the control that opened it instead of at nothing. - transform-origin is written to the arrow's position on every measurement, so the panel scales out of its trigger rather than out of its own middle, and under prefers-reduced-motion it simply arrives in place, still fully rendered. - Scroll, resize and content changes reposition through one rAF that writes left, top and the arrow offset straight to the DOM nodes; React re-renders only when the resolved side genuinely flips, so tracking a scrolling anchor costs no renders. - Available room caps the content box, which scrolls inside its own overscroll-contained region, so the panel never animates toward an unbounded height and never grows past the boundary it was given. - Escape closes and returns focus to the trigger, moving focus out of the panel closes it, an outside pointer closes it, and the trigger carries aria-haspopup, aria-expanded and aria-controls against a labelled dialog. ### Source (`components/interior/popover.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const RADIUS = 11; const MIN_W = 160; const MIN_H = 88; const useIsoLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect; export type PopoverSide = "top" | "right" | "bottom" | "left"; export type PopoverAlign = "start" | "center" | "end"; const FLIP: Record = { top: "bottom", bottom: "top", left: "right", right: "left", }; const ARROW_EDGE: Record = { bottom: "border-t border-l", top: "border-b border-r", right: "border-b border-l", left: "border-t border-r", }; const FROM: Record = { top: { y: 6 }, bottom: { y: -6 }, left: { x: 6 }, right: { x: -6 }, }; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), Math.max(min, max)); } export type UsePopoverOptions = { open: boolean; side?: PopoverSide; align?: PopoverAlign; offset?: number; padding?: number; arrowSize?: number; boundary?: React.RefObject; }; export type UsePopoverResult = { anchorRef: React.RefObject; floatingRef: React.RefObject; panelRef: React.RefObject; contentRef: React.RefObject; arrowRef: React.RefObject; side: PopoverSide; update: () => void; }; export function usePopover({ open, side = "bottom", align = "center", offset = 10, padding = 8, arrowSize = 9, boundary, }: UsePopoverOptions): UsePopoverResult { const anchorRef = useRef(null); const floatingRef = useRef(null); const panelRef = useRef(null); const contentRef = useRef(null); const arrowRef = useRef(null); const [resolved, setResolved] = useState(side); const update = useCallback(() => { const anchor = anchorRef.current; const wrap = floatingRef.current; const panel = panelRef.current; if (!anchor || !wrap || !panel) return; const content = contentRef.current; panel.style.maxWidth = ""; if (content) content.style.maxHeight = ""; const a = anchor.getBoundingClientRect(); const b = boundary?.current?.getBoundingClientRect() ?? null; const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const left = b ? Math.max(padding, b.left + padding) : padding; const top = b ? Math.max(padding, b.top + padding) : padding; const right = b ? Math.min(vw - padding, b.right - padding) : vw - padding; const bottom = b ? Math.min(vh - padding, b.bottom - padding) : vh - padding; panel.style.maxWidth = `${Math.max(MIN_W, right - left)}px`; const room: Record = { top: a.top - top - offset, bottom: bottom - a.bottom - offset, left: a.left - left - offset, right: right - a.right - offset, }; let next = side; const wanted = next === "top" || next === "bottom" ? panel.offsetHeight : panel.offsetWidth; if (room[next] < wanted && room[FLIP[next]] > room[next]) next = FLIP[next]; const horizontal = next === "top" || next === "bottom"; if (!horizontal) { panel.style.maxWidth = `${Math.max(MIN_W, Math.min(right - left, room[next]))}px`; } if (content) { const chrome = panel.offsetHeight - content.offsetHeight; const allowed = horizontal ? room[next] : bottom - top; content.style.maxHeight = `${Math.max(MIN_H, allowed - chrome)}px`; } const w = panel.offsetWidth; const h = panel.offsetHeight; let x: number; let y: number; if (horizontal) { y = next === "top" ? a.top - offset - h : a.bottom + offset; x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2; } else { x = next === "left" ? a.left - offset - w : a.right + offset; y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2; } x = clamp(x, left, right - w); y = clamp(y, top, bottom - h); const base = wrap.getBoundingClientRect(); const originX = base.left - (parseFloat(wrap.style.left) || 0); const originY = base.top - (parseFloat(wrap.style.top) || 0); wrap.style.left = `${Math.round(x - originX)}px`; wrap.style.top = `${Math.round(y - originY)}px`; const half = arrowSize / 2; const point = horizontal ? clamp(a.left + a.width / 2 - x, RADIUS + half, w - RADIUS - half) : clamp(a.top + a.height / 2 - y, RADIUS + half, h - RADIUS - half); panel.style.transformOrigin = horizontal ? `${Math.round(point)}px ${next === "top" ? h : 0}px` : `${next === "left" ? w : 0}px ${Math.round(point)}px`; const arrow = arrowRef.current; if (arrow) { if (horizontal) { arrow.style.left = `${Math.round(point - half)}px`; arrow.style.top = `${Math.round(next === "top" ? h - half : -half)}px`; } else { arrow.style.top = `${Math.round(point - half)}px`; arrow.style.left = `${Math.round(next === "left" ? w - half : -half)}px`; } } setResolved((prev) => (prev === next ? prev : next)); }, [side, align, offset, padding, arrowSize, boundary]); useIsoLayoutEffect(() => { if (!open) return; update(); }, [open, update]); useEffect(() => { if (!open) return; let frame = 0; const schedule = () => { if (frame) return; frame = requestAnimationFrame(() => { frame = 0; update(); }); }; const observer = new ResizeObserver(schedule); if (anchorRef.current) observer.observe(anchorRef.current); if (contentRef.current) observer.observe(contentRef.current); window.addEventListener("scroll", schedule, true); window.addEventListener("resize", schedule); return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener("scroll", schedule, true); window.removeEventListener("resize", schedule); }; }, [open, update]); return { anchorRef, floatingRef, panelRef, contentRef, arrowRef, side: resolved, update }; } export type PopoverProps = { trigger: React.ReactNode; children: React.ReactNode; label: string; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; side?: PopoverSide; align?: PopoverAlign; offset?: number; padding?: number; arrowSize?: number; boundary?: React.RefObject; triggerClassName?: string; className?: string; }; export function Popover({ trigger, children, label, open: controlled, defaultOpen = false, onOpenChange, side = "bottom", align = "center", offset = 10, padding = 8, arrowSize = 9, boundary, triggerClassName = "", className = "", }: PopoverProps) { const [uncontrolled, setUncontrolled] = useState(defaultOpen); const open = controlled ?? uncontrolled; const id = useId(); const reduced = useReducedMotion(); const notify = useRef(onOpenChange); notify.current = onOpenChange; const { anchorRef, floatingRef, panelRef, contentRef, arrowRef, side: at } = usePopover({ open, side, align, offset, padding, arrowSize, boundary, }); const setOpen = useCallback( (next: boolean) => { if (controlled === undefined) setUncontrolled(next); notify.current?.(next); }, [controlled], ); useEffect(() => { if (!open) return; panelRef.current?.focus({ preventScroll: true }); }, [open, panelRef]); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (!target) return; if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; setOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.stopPropagation(); anchorRef.current?.focus({ preventScroll: true }); setOpen(false); }; document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("keydown", onKeyDown, true); return () => { document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("keydown", onKeyDown, true); }; }, [open, setOpen, anchorRef, panelRef]); return ( <> {open ? (
{ const next = event.relatedTarget as Node | null; if (!next) return; if (panelRef.current?.contains(next) || anchorRef.current?.contains(next)) return; setOpen(false); }} >
{children}
) : null}
); } ```