Skip to content
UI Kit
Accent color

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

2 variants

Variants

Share

A small form in a panel. Focus moves in when it opens and back to the button when it closes.

Details

Information on demand, here the opening hours of a café next to its address.

Details

What Popover brings along, read from the registry and the source.

Category
Overlays
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

Props

The PopoverProps interface from components/ui/popover.tsx, with the comments from the code.

  • triggerrequired
    React.ReactElement

    The button that opens it. It gets aria-expanded and aria-controls.

  • sideoptional
    Side

    topbottomleftright

  • alignoptional
    Align

    startcenterend

  • openoptional
    boolean
  • defaultOpenoptional
    boolean
  • onOpenChangeoptional
    (open: boolean) => void
  • labeloptional
    string

    Accessible name of the panel, when it has no visible title

  • arrowoptional
    boolean

    Small arrow pointing at the trigger

  • childrenrequired
    React.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
components/ui/popover.tsx
'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.

Often searched

Jump to

Copied to the clipboard