Popover
Ein Panel neben dem Auslöser für kurze Formulare und Details. Klappt um, wenn kein Platz ist, Escape und ein Klick daneben schließen.
Eine von 9 Components in Overlays
Details
Was Popover mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Overlays
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/popover.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PopoverProps aus components/ui/popover.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
triggerPflichtReact.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
childrenPflichtReact.ReactNode | ((close: () => void) => React.ReactNode)
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Popover reagiert selbst auf die Tasten Escape.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle dialog.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 308 Zeilen
'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>
)}
</>
);
}Häufige Fragen
Wie installiere ich Popover?
Mit aiw add @ai-website/popover. Der Befehl legt die Datei components/ui/popover.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Popover?
2 Varianten, Teilen und Details.
Kann ein KI-Assistent Popover einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Popover mit aiw_components und installieren sie mit aiw_add @ai-website/popover.