UIOverlays and Menusmotion/react

Popover

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 props
10
320

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add popover

Writes 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.

example.tsx
1import { Popover } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Popover />6  );7}

Props

PropTypeDefault
open

Controlled open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called whenever the popover requests to open or close.

(open: boolean) => voidundefined
side

Preferred side of the trigger; flips to the opposite side when it does not fit.

'top' | 'right' | 'bottom' | 'left''bottom'
align

Alignment along the trigger edge; the panel is clamped inside the viewport.

'start' | 'center' | 'end''center'
sideOffset

Gap between trigger and panel in pixels.

number10
showArrow

Show the arrow pointing at the trigger.

booleantrue
triggerLabel

Label of the trigger button.

string'Open popover'
title

Panel heading, used as the accessible name.

string'Dimensions'
description

Supporting text under the heading, used as the accessible description.

string'Set the dimensions for the layer.'
children

Panel content; defaults to four labelled dimension inputs.

React.ReactNodedimensions form
width

Panel width in pixels (capped to the viewport).

number320
accentColor

Focus ring color of the inputs.

string'#6366f1'
closeOnOutsideClick

Close when pressing outside the trigger and panel.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/popover.tsx
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}