Popover
A panel next to a trigger for short forms and details. Flips when there is no room, Escape and a click outside close it.
One of 9 components in Overlays
Details
What Popover brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/popover.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PopoverProps interface from components/ui/popover.tsx, with the comments from the code.
triggerrequiredReact.ReactElementThe button that opens it. It gets aria-expanded and aria-controls.
sideoptionalSidetopbottomleftright
alignoptionalAlignstartcenterend
openoptionalbooleandefaultOpenoptionalbooleanonOpenChangeoptional(open: boolean) => voidlabeloptionalstringAccessible name of the panel, when it has no visible title
arrowoptionalbooleanSmall arrow pointing at the trigger
childrenrequiredReact.ReactNode | ((close: () => void) => React.ReactNode)
Accessibility
Read from the source of the component.
- Popover handles the keys Escape itself.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role dialog.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 308 lines
'use client';
import { cloneElement, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, prefersReducedMotion } from '@/lib/kit';
/*
* Popover and the positioning every floating element of the kit shares
* (tooltip, dropdown menu, select, combobox, date input). The panel sits in
* a portal with position: fixed, flips to the other side when there is no
* room, stays 8 px inside the viewport and follows scroll and resize.
*/
export type Side = 'top' | 'bottom' | 'left' | 'right';
export type Align = 'start' | 'center' | 'end';
export interface FloatingPosition {
x: number;
y: number;
side: Side;
/** Where the arrow should point, along the panel edge, in px */
arrow: number;
}
const MARGIN = 8;
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), Math.max(min, max));
/** Computes the position of a floating element next to an anchor rectangle. */
export function computePosition(a: DOMRect, fw: number, fh: number, side: Side, align: Align, offset: number): FloatingPosition {
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
let s = side;
if (s === 'bottom' && a.bottom + offset + fh > vh - MARGIN && a.top - offset - fh >= MARGIN) s = 'top';
else if (s === 'top' && a.top - offset - fh < MARGIN && a.bottom + offset + fh <= vh - MARGIN) s = 'bottom';
else if (s === 'right' && a.right + offset + fw > vw - MARGIN && a.left - offset - fw >= MARGIN) s = 'left';
else if (s === 'left' && a.left - offset - fw < MARGIN && a.right + offset + fw <= vw - MARGIN) s = 'right';
// Left and right fall back to below when the screen is too narrow for either
if ((s === 'left' && a.left - offset - fw < MARGIN) || (s === 'right' && a.right + offset + fw > vw - MARGIN)) s = 'bottom';
let x: number;
let y: number;
if (s === 'top' || s === 'bottom') {
y = s === 'bottom' ? a.bottom + offset : a.top - offset - fh;
x = align === 'start' ? a.left : align === 'end' ? a.right - fw : a.left + a.width / 2 - fw / 2;
x = clamp(x, MARGIN, vw - MARGIN - fw);
} else {
x = s === 'right' ? a.right + offset : a.left - offset - fw;
y = align === 'start' ? a.top : align === 'end' ? a.bottom - fh : a.top + a.height / 2 - fh / 2;
y = clamp(y, MARGIN, vh - MARGIN - fh);
}
const arrow = s === 'top' || s === 'bottom' ? clamp(a.left + a.width / 2 - x, 12, fw - 12) : clamp(a.top + a.height / 2 - y, 10, fh - 10);
return { x: Math.round(x), y: Math.round(y), side: s, arrow: Math.round(arrow) };
}
export interface FloatingProps {
/** The element the panel belongs to, or a function returning a rectangle (context menus) */
anchor: React.RefObject<HTMLElement | null> | (() => DOMRect | null);
side?: Side;
align?: Align;
offset?: number;
/** Panel at least as wide as the anchor (selects, comboboxes) */
matchWidth?: boolean;
className?: string;
style?: React.CSSProperties;
children: (pos: FloatingPosition | null) => React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}
/** Client-only portal that positions its child next to the anchor. Render it only while open. */
export function Floating({ anchor, side = 'bottom', align = 'center', offset = 8, matchWidth, className, style, children, ref }: FloatingProps) {
const own = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<FloatingPosition | null>(null);
const [minWidth, setMinWidth] = useState<number>();
const setRefs = useCallback(
(el: HTMLDivElement | null) => {
own.current = el;
if (typeof ref === 'function') ref(el);
else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = el;
},
[ref],
);
useLayoutEffect(() => {
const update = () => {
const el = own.current;
const rect = typeof anchor === 'function' ? anchor() : anchor.current?.getBoundingClientRect() ?? null;
if (!el || !rect) return;
if (matchWidth) setMinWidth(Math.round(rect.width));
setPos(computePosition(rect, el.offsetWidth, el.offsetHeight, side, align, offset));
};
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
const ro = new ResizeObserver(update);
if (own.current) ro.observe(own.current);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
ro.disconnect();
};
}, [anchor, side, align, offset, matchWidth]);
return createPortal(
<div
ref={setRefs}
data-side={pos?.side ?? side}
className={cn('fixed left-0 top-0 z-50', className)}
style={{ ...style, transform: `translate(${pos?.x ?? 0}px, ${pos?.y ?? 0}px)`, visibility: pos ? undefined : 'hidden', minWidth }}
>
{children(pos)}
</div>,
document.body,
);
}
/** true after the first client render. Floating panels need the DOM. */
export function useMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}
/** Closes on Escape and on a pointer down outside the given elements */
export function useDismiss(open: boolean, close: (reason: 'escape' | 'outside') => void, refs: React.RefObject<HTMLElement | null>[]) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close('escape');
}
};
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (refs.some((r) => r.current?.contains(t))) return;
close('outside');
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onDown);
};
}, [open, close, refs]);
}
/** Open state that can be controlled or not */
export function useOpen(open: boolean | undefined, defaultOpen: boolean | undefined, onOpenChange?: (open: boolean) => void) {
const [inner, setInner] = useState(defaultOpen ?? false);
const value = open ?? inner;
const set = useCallback(
(next: boolean) => {
setInner(next);
onOpenChange?.(next);
},
[onOpenChange],
);
return [value, set] as const;
}
/** The entrance every floating panel uses: a short fade and scale from the anchor side */
export const floatingMotion =
'origin-[var(--origin)] transition-[opacity,scale] duration-150 ease-[var(--ease-kit)] starting:scale-[0.96] starting:opacity-0 motion-reduce:transition-none';
/**
* Entrance and exit for panels that stay mounted while they leave (see usePresence):
* a soft spring from the anchor point in, a quick fade and shrink toward it out.
* Put data-closing on the panel while it leaves.
*/
export const floatingPop =
'origin-[var(--origin)] transition-[opacity,scale] duration-[260ms] ease-[cubic-bezier(0.34,1.4,0.64,1)] starting:scale-[0.92] starting:opacity-0 data-[closing]:pointer-events-none data-[closing]:scale-[0.96] data-[closing]:opacity-0 data-[closing]:duration-150 data-[closing]:ease-[var(--ease-kit)] motion-reduce:transition-none';
export const ORIGIN: Record<Side, string> = { bottom: 'top center', top: 'bottom center', left: 'center right', right: 'center left' };
/** Transform origin on the panel edge right where the anchor sits, so the panel grows out of its trigger */
export function originFor(pos: FloatingPosition | null, side: Side) {
if (!pos) return ORIGIN[side];
if (pos.side === 'bottom') return `${pos.arrow}px top`;
if (pos.side === 'top') return `${pos.arrow}px bottom`;
if (pos.side === 'right') return `left ${pos.arrow}px`;
return `right ${pos.arrow}px`;
}
/**
* Keeps a panel mounted for its exit animation. `present` stays true for
* `exitMs` after `open` turns false, `closing` is true during that time.
* With reduced motion it unmounts right away.
*/
export function usePresence(open: boolean, exitMs = 160) {
const [present, setPresent] = useState(open);
useEffect(() => {
if (open) {
setPresent(true);
return;
}
if (prefersReducedMotion()) {
setPresent(false);
return;
}
const t = window.setTimeout(() => setPresent(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { present: open || present, closing: !open && present };
}
/** The surface of every floating panel */
export const panelSurface = 'rounded-xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]';
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
export interface PopoverProps {
/** The button that opens it. It gets aria-expanded and aria-controls. */
trigger: React.ReactElement;
side?: Side;
align?: Align;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Accessible name of the panel, when it has no visible title */
label?: string;
/** Small arrow pointing at the trigger */
arrow?: boolean;
className?: string;
children: React.ReactNode | ((close: () => void) => React.ReactNode);
}
/** The small pointer on a floating panel, drawn as a rotated square with two borders */
export function FloatingArrow({ pos, className }: { pos: FloatingPosition | null; className?: string }) {
if (!pos) return null;
const s = pos.side;
const style: React.CSSProperties =
s === 'bottom' ? { top: -5, left: pos.arrow - 5 } : s === 'top' ? { bottom: -5, left: pos.arrow - 5 } : s === 'right' ? { left: -5, top: pos.arrow - 5 } : { right: -5, top: pos.arrow - 5 };
const borders = s === 'bottom' ? 'border-l border-t' : s === 'top' ? 'border-r border-b' : s === 'right' ? 'border-l border-b' : 'border-r border-t';
return <span aria-hidden className={cn('absolute size-2.5 rotate-45 border-border bg-popover', borders, className)} style={style} />;
}
/**
* A panel next to a trigger for small forms, details and pickers.
* <Popover trigger={<Button variant="outline">Share</Button>}>…</Popover>
*/
export function Popover({ trigger, side = 'bottom', align = 'center', open: openProp, defaultOpen, onOpenChange, label, arrow, className, children }: PopoverProps) {
const [open, setOpen] = useOpen(openProp, defaultOpen, onOpenChange);
const mounted = useMounted();
const anchorRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const userOpened = useRef(false);
const id = useId();
const { present, closing } = usePresence(open);
const close = useCallback(
(reason?: 'escape' | 'outside') => {
setOpen(false);
if (reason !== 'outside') anchorRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
},
[setOpen],
);
const refs = useRef([anchorRef, panelRef]).current;
useDismiss(open, close, refs);
useEffect(() => {
if (!open || !userOpened.current) return;
const id = requestAnimationFrame(() => {
const first = panelRef.current?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panelRef.current)?.focus();
});
return () => cancelAnimationFrame(id);
}, [open]);
const triggerEl = isValidElement<Record<string, unknown>>(trigger)
? cloneElement(trigger, {
'aria-expanded': open,
'aria-haspopup': 'dialog',
'aria-controls': open ? id : undefined,
onClick: (e: React.MouseEvent) => {
(trigger.props.onClick as ((e: React.MouseEvent) => void) | undefined)?.(e);
userOpened.current = true;
setOpen(!open);
},
})
: trigger;
return (
<>
<span ref={anchorRef} className="inline-flex">
{triggerEl}
</span>
{mounted && present && (
<Floating anchor={anchorRef} side={side} align={align} offset={arrow ? 10 : 8} ref={panelRef}>
{(pos) => (
<div
id={id}
role="dialog"
aria-label={label}
tabIndex={-1}
data-closing={closing || undefined}
inert={closing || undefined}
className={cn('relative w-72 max-w-[calc(100vw-16px)] p-4 outline-none', panelSurface, floatingPop, className)}
style={{ '--origin': originFor(pos, side) } as React.CSSProperties}
>
{arrow && <FloatingArrow pos={pos} />}
{typeof children === 'function' ? children(() => close()) : children}
</div>
)}
</Floating>
)}
</>
);
}Frequent questions
How do I install Popover?
With aiw add @ai-website/popover. The command puts components/ui/popover.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Popover have?
2 variants, Share and Details.
Can an AI assistant build with Popover?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Popover with aiw_components and install it with aiw_add @ai-website/popover.