A click-triggered floating panel with a pointing arrow that scales in from the trigger. Placement is measured against the viewport: it honours side and align, flips to the opposite side when there is no room and keeps the arrow pointed at the trigger.
Preview
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add popoverWrites components/ui/popover.tsx and components/styles/popover.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Popover } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Popover />6 );7}Props
Accessibility
The trigger is a button with aria-haspopup="dialog", aria-expanded and aria-controls. The panel is a non-modal role="dialog" labelled by its heading and described by its description. Focus moves to the first field on open; Escape closes and returns focus to the trigger, as does the labelled Close button. Pressing outside or tabbing out of the panel closes it. All inputs have associated labels. The arrow is aria-hidden. Under prefers-reduced-motion the panel only fades.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/popover.module.css";7 8type Side = "top" | "right" | "bottom" | "left";9type Align = "start" | "center" | "end";10 11export interface PopoverProps {12 /** Controlled open state */13 open?: boolean;14 /** Initial open state when uncontrolled */15 defaultOpen?: boolean;16 /** Called whenever the popover requests to open or close */17 onOpenChange?: (open: boolean) => void;18 /** Preferred side of the trigger (flips when there is no room) */19 side?: Side;20 /** Alignment along the trigger edge */21 align?: Align;22 /** Gap between trigger and panel in pixels */23 sideOffset?: number;24 /** Show the pointing arrow */25 showArrow?: boolean;26 /** Label of the trigger button */27 triggerLabel?: string;28 /** Panel heading (accessible name) */29 title?: string;30 /** Supporting text under the heading */31 description?: string;32 /** Panel content; defaults to a dimensions form */33 children?: React.ReactNode;34 /** Panel width in pixels */35 width?: number;36 /** Accent color for inputs and focus highlights */37 accentColor?: string;38 /** Close when pressing outside the panel */39 closeOnOutsideClick?: boolean;40 /** Optional className merged onto the root */41 className?: string;42 /** Optional inline styles merged onto the root */43 style?: React.CSSProperties;44}45 46const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];47const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';48const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;49const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };50const MARGIN = 8;51 52function readThemeVars(el: Element | null): Record<string, string> {53 const out: Record<string, string> = {};54 if (!el) return out;55 const cs = getComputedStyle(el);56 for (const t of THEME_TOKENS) {57 const v = cs.getPropertyValue(t).trim();58 if (v) out[t] = v;59 }60 return out;61}62 63const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(v, Math.max(lo, hi)));64 65interface Placement {66 side: Side;67 left: number;68 top: number;69 arrow: number;70}71 72/** Measured placement: preferred side, flipped to the opposite side when it does not fit, then clamped into the viewport. */73function computePlacement(r: DOMRect, w: number, h: number, side: Side, align: Align, gap: number): Placement {74 const vw = window.innerWidth;75 const vh = window.innerHeight;76 const room: Record<Side, number> = { bottom: vh - r.bottom, top: r.top, right: vw - r.right, left: r.left };77 const need = (s: Side) => (s === "top" || s === "bottom" ? h : w) + gap + MARGIN;78 const s = room[side] < need(side) && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;79 if (s === "top" || s === "bottom") {80 const raw = align === "start" ? r.left : align === "end" ? r.right - w : r.left + r.width / 2 - w / 2;81 const left = clamp(raw, MARGIN, vw - w - MARGIN);82 return { side: s, left, top: s === "bottom" ? r.bottom + gap : r.top - gap - h, arrow: clamp(r.left + r.width / 2 - left, 14, w - 14) };83 }84 const raw = align === "start" ? r.top : align === "end" ? r.bottom - h : r.top + r.height / 2 - h / 2;85 const top = clamp(raw, MARGIN, vh - h - MARGIN);86 return { side: s, top, left: s === "right" ? r.right + gap : r.left - gap - w, arrow: clamp(r.top + r.height / 2 - top, 14, h - 14) };87}88 89const FIELDS = [90 ["Width", "100%"],91 ["Max. width", "300px"],92 ["Height", "25px"],93 ["Max. height", "none"],94];95 96function DefaultBody({ id }: { id: string }) {97 return (98 <div className={styles.form}>99 {FIELDS.map(([label, value], i) => (100 <div key={label} className={styles.field}>101 <label htmlFor={`${id}-f${i}`} className={styles.label}>102 {label}103 </label>104 <input id={`${id}-f${i}`} className={styles.input} defaultValue={value} />105 </div>106 ))}107 </div>108 );109}110 111export function Popover({112 open: openProp,113 defaultOpen = false,114 onOpenChange,115 side = "bottom",116 align = "center",117 sideOffset = 10,118 showArrow = true,119 triggerLabel = "Open popover",120 title = "Dimensions",121 description = "Set the dimensions for the layer.",122 children,123 width = 320,124 accentColor = "#6366f1",125 closeOnOutsideClick = true,126 className = "",127 style,128}: PopoverProps) {129 const reduce = useReducedMotion();130 const [inner, setInner] = React.useState(defaultOpen);131 const open = openProp !== undefined ? openProp : inner;132 const setOpen = React.useCallback(133 (v: boolean) => {134 if (openProp === undefined) setInner(v);135 onOpenChange?.(v);136 },137 [openProp, onOpenChange],138 );139 const setOpenRef = React.useRef(setOpen);140 setOpenRef.current = setOpen;141 const [mounted, setMounted] = React.useState(false);142 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});143 const [placement, setPlacement] = React.useState<Placement | null>(null);144 const triggerRef = React.useRef<HTMLButtonElement>(null);145 const panelRef = React.useRef<HTMLDivElement>(null);146 const id = React.useId();147 148 React.useEffect(() => setMounted(true), []);149 150 const place = React.useCallback(() => {151 const t = triggerRef.current;152 const p = panelRef.current;153 if (!t || !p) return;154 setPlacement(computePlacement(t.getBoundingClientRect(), p.offsetWidth, p.offsetHeight, side, align, sideOffset + (showArrow ? 2 : 0)));155 }, [side, align, sideOffset, showArrow]);156 157 useIsoLayoutEffect(() => {158 if (!open) return;159 setThemeVars(readThemeVars(triggerRef.current));160 place();161 const onDown = (e: PointerEvent) => {162 const target = e.target as Node;163 if (closeOnOutsideClick && !triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) setOpenRef.current(false);164 };165 const onKey = (e: KeyboardEvent) => {166 if (e.key !== "Escape") return;167 e.preventDefault();168 setOpenRef.current(false);169 triggerRef.current?.focus();170 };171 const raf = requestAnimationFrame(() => (panelRef.current?.querySelector<HTMLElement>(FOCUSABLE) ?? panelRef.current)?.focus());172 window.addEventListener("resize", place);173 window.addEventListener("scroll", place, true);174 document.addEventListener("pointerdown", onDown);175 document.addEventListener("keydown", onKey);176 return () => {177 cancelAnimationFrame(raf);178 window.removeEventListener("resize", place);179 window.removeEventListener("scroll", place, true);180 document.removeEventListener("pointerdown", onDown);181 document.removeEventListener("keydown", onKey);182 };183 }, [open, place, closeOnOutsideClick]);184 185 const s = placement?.side ?? side;186 const vertical = s === "top" || s === "bottom";187 const shift = 6 * (s === "bottom" || s === "right" ? -1 : 1);188 const origin = placement ? (vertical ? `${placement.arrow}px ${s === "bottom" ? "0" : "100%"}` : `${s === "right" ? "0" : "100%"} ${placement.arrow}px`) : "center";189 190 return (191 <div className={`${styles.root} ${className}`.trim()} style={{ "--mf-pop-accent": accentColor, ...style } as React.CSSProperties}>192 <button193 ref={triggerRef}194 type="button"195 aria-haspopup="dialog"196 aria-expanded={open}197 aria-controls={open ? `${id}-panel` : undefined}198 className={styles.trigger}199 onClick={() => setOpen(!open)}200 >201 {triggerLabel}202 </button>203 {mounted &&204 createPortal(205 <AnimatePresence>206 {open && (207 <motion.div208 ref={panelRef}209 id={`${id}-panel`}210 role="dialog"211 aria-labelledby={`${id}-title`}212 aria-describedby={description ? `${id}-desc` : undefined}213 tabIndex={-1}214 data-side={s}215 className={styles.panel}216 style={217 {218 ...themeVars,219 "--mf-pop-accent": accentColor,220 "--mf-pop-w": `${width}px`,221 "--mf-pop-arrow": `${placement?.arrow ?? 0}px`,222 left: placement?.left ?? 0,223 top: placement?.top ?? 0,224 visibility: placement ? "visible" : "hidden",225 transformOrigin: origin,226 } as React.CSSProperties227 }228 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, x: vertical ? 0 : shift, y: vertical ? shift : 0 }}229 animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}230 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: vertical ? 0 : shift / 2, y: vertical ? shift / 2 : 0 }}231 transition={{ duration: reduce ? 0.08 : 0.18, ease: [0.16, 1, 0.3, 1] }}232 onBlur={(e) => {233 const next = e.relatedTarget as Node | null;234 if (next && !e.currentTarget.contains(next) && !triggerRef.current?.contains(next)) setOpen(false);235 }}236 >237 {showArrow && <span aria-hidden="true" className={styles.arrow} />}238 <div className={styles.header}>239 <h3 id={`${id}-title`} className={styles.title}>240 {title}241 </h3>242 {description && (243 <p id={`${id}-desc`} className={styles.description}>244 {description}245 </p>246 )}247 </div>248 {children ?? <DefaultBody id={id} />}249 <button250 type="button"251 aria-label="Close"252 className={styles.close}253 onClick={() => {254 setOpen(false);255 triggerRef.current?.focus();256 }}257 >258 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">259 <path d="M18 6 6 18M6 6l12 12" />260 </svg>261 </button>262 </motion.div>263 )}264 </AnimatePresence>,265 document.body,266 )}267 </div>268 );269}