Components

Getting started

Components144

More

A small panel that belongs to a button: it rises off the trigger from the nearest point, flips to fit, and hands focus to what is inside.

Used forQuick editsShare settingsFiltersDetails on demand

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/popover.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Quick edits

    Rename a file or set a due date without leaving the list.

  • Share settings

    A link with access options under a Share button.

  • Filters

    A few fields behind a Filter button.

  • Details on demand

    More about a plan, a user or a status.

What it handles for you

Positioning, focus handover, Escape and outside presses are where most popovers go wrong; this one does all four.

Instead of wiring upRadix Popoverfloating-uifocus management
  1. From the trigger

    transform-origin is the point on the trigger nearest the panel.

  2. Stays in view

    Flips sides and shifts; follows scroll and resize.

  3. Focus handover

    In to the first field, back to the trigger on Escape.

  4. Close from inside

    children can be a function that receives close().

Overview

Popovers often appear from nowhere, cover their trigger and leave focus behind. This one rises off its trigger from the point nearest to it, flips and shifts to stay in the window, moves focus to the first field inside, and on Escape gives focus back to the trigger. For a list of actions, use the dropdown menu.

States

open
Rises 6px from its side, out of a 4px blur, from 0.96 (320ms).
inside
Focus on the first field or the panel.
close
Escape returns focus to the trigger; a press outside just closes (180ms).

Usage

usage.tsx
import { Popover } from "@/components/ui/popover";<Popover label="Rename" trigger={<button>Rename</button>}>  {({ close }) => <form onSubmit={(e) => { e.preventDefault(); save(); close(); }}>…</form>}</Popover>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Props

PropTypeDescription
triggerReactElementThe button; it gets aria-expanded and the click.
childrenReactNode | ({ close }) => ReactNodeThe content.
side / alignSide / Aligndefault "bottom" / "center"Where it sits.
open / onOpenChangeboolean / (open) => voidControl it.
widthnumberdefault 280Panel width.
labelstringAccessible name of the panel.

Accessibility and motion

  • Placement, presence and dismissal live in floating.ts, shared by tooltip, popover, dropdown and context menus: they all flip, shift, enter and leave the same way.
  • The panel is role="dialog"; the trigger gets aria-haspopup and aria-expanded.
  • Reduced motion: it fades.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/popover.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { enterStyle, themeVars, useDismiss, useFloating, usePresence, type Align, type Side } from "./floating";import { useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * POPOVER: a small panel that belongs to a button * *   open     the panel rises off its trigger out of a blur, from *            the point on the trigger nearest to it, and flips *            or shifts to stay in the window *   inside   focus moves to the first field in it (or the panel) *   close    Escape returns focus to the trigger; a press *            outside just closes; `close()` from inside too * * For a list of actions use DropdownMenu; this is for content * (a short form, a colour picker, details). * ───────────────────────────────────────────────────────── */export interface PopoverProps {  trigger: React.ReactElement<React.HTMLAttributes<HTMLElement>>;  /** The content, or a function that gets `close`. */  children: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);  side?: Side;  align?: Align;  open?: boolean;  onOpenChange?: (open: boolean) => void;  /** Width of the panel in px. */  width?: number;  /** Accessible name of the panel. */  label?: string;  className?: string;}const FOCUSABLE = 'input, textarea, select, button, [href], [tabindex]:not([tabindex="-1"])';export function Popover({ trigger, children, side = "bottom", align = "center", open: controlled, onOpenChange, width = 280, label, className = "" }: PopoverProps) {  const reduced = useInteractionReducedMotion();  const anchor = React.useRef<HTMLSpanElement>(null);  const surface = React.useRef<HTMLDivElement>(null);  const [own, setOwn] = React.useState(false);  const open = controlled ?? own;  const { mounted, shown } = usePresence(open, 180);  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 8 });  const [vars, setVars] = React.useState<React.CSSProperties>({});  const id = React.useId();  const triggerId = React.useId();  const setOpen = (next: boolean, refocus = false) => {    if (controlled === undefined) setOwn(next);    onOpenChange?.(next);    // By id rather than a ref: this runs from handlers that are created while rendering.    if (!next && refocus) document.getElementById(trigger.props.id ?? triggerId)?.focus();  };  useDismiss(open, (reason) => setOpen(false, reason === "escape"), [anchor, surface]);  React.useLayoutEffect(() => {    if (mounted) setVars(themeVars(anchor.current));  }, [mounted]);  React.useEffect(() => {    if (!open || !place.ready) return;    const el = surface.current;    (el?.querySelector<HTMLElement>(FOCUSABLE) ?? el)?.focus({ preventScroll: true });  }, [open, place.ready]);  const close = () => setOpen(false, true);  return (    <>      <span ref={anchor} className="inline-flex">        {React.cloneElement(trigger, {          id: trigger.props.id ?? triggerId,          "aria-haspopup": "dialog",          "aria-expanded": open,          "aria-controls": open ? id : undefined,          onClick: (e: React.MouseEvent<HTMLElement>) => {            trigger.props.onClick?.(e);            setOpen(!open);          },        })}      </span>      {mounted &&        createPortal(          <div            ref={surface}            id={id}            role="dialog"            aria-label={label}            tabIndex={-1}            className={`fixed left-0 top-0 z-[55] max-w-[calc(100vw_-_16px)] rounded-[16px] bg-popover p-4 text-[13.5px] text-popover-foreground shadow-[0_0_0_1px_var(--border)] outline-none ${className}`}            style={{ ...vars, width, left: place.x, top: place.y, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}          >            {typeof children === "function" ? children({ close }) : children}          </div>,          document.body,        )}    </>  );}export default Popover;
components/floating.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FLOATING: what every popover, menu and tooltip shares * *   place     beside an anchor on a side, lined up start, centre *             or end; flips to the other side when it doesn't fit *             and shifts to stay `padding` inside the viewport; *             follows scroll and resize *   presence  mounts on open, shows a frame later (so the entry *             transitions run), hides, then unmounts after `exit` *   dismiss   Escape and a press outside both close it *   theme     the portal carries the anchor's theme tokens, so a *             light specimen on a dark page stays light * ───────────────────────────────────────────────────────── */export type Side = "top" | "bottom" | "left" | "right";export type Align = "start" | "center" | "end";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring", "--card"];/** The anchor's theme tokens as inline styles for something portalled out of its tree. */export function themeVars(anchor: Element | null): React.CSSProperties {  if (!anchor) return {};  const style = getComputedStyle(anchor);  const vars: Record<string, string> = {};  for (const t of TOKENS) {    const v = style.getPropertyValue(t);    if (v) vars[t] = v;  }  return vars as React.CSSProperties;}const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };export interface Placement {  x: number;  y: number;  side: Side;  /** Where the surface grows from, for transform-origin. */  origin: string;  ready: boolean;}/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */export function useFloating({  open,  anchor,  point,  floating,  side = "bottom",  align = "center",  offset = 8,  padding = 8,}: {  open: boolean;  anchor?: React.RefObject<Element | null>;  point?: { x: number; y: number } | null;  floating: React.RefObject<HTMLElement | null>;  side?: Side;  align?: Align;  offset?: number;  padding?: number;}): Placement {  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: "center", ready: false });  React.useLayoutEffect(() => {    if (!open) return;    let frame = 0;    const measure = () => {      const el = floating.current;      if (!el) return;      const a = point        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }        : anchor?.current?.getBoundingClientRect();      if (!a) return;      const w = el.offsetWidth;      const h = el.offsetHeight;      const vw = window.innerWidth;      const vh = window.innerHeight;      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };      const need = side === "top" || side === "bottom" ? h + offset : w + offset;      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;      let x: number;      let y: number;      if (s === "top" || s === "bottom") {        y = s === "bottom" ? a.bottom + offset : a.top - offset - h;        x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2;        if (point) x = a.left;      } else {        x = s === "right" ? a.right + offset : a.left - offset - w;        y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + a.height / 2 - h / 2;      }      x = Math.max(padding, Math.min(vw - padding - w, x));      y = Math.max(padding, Math.min(vh - padding - h, y));      // Grow from the point on the anchor's edge nearest the surface.      const ox = s === "left" ? "100%" : s === "right" ? "0%" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;      const oy = s === "top" ? "100%" : s === "bottom" ? "0%" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));    };    const schedule = () => {      cancelAnimationFrame(frame);      frame = requestAnimationFrame(measure);    };    measure();    const ro = new ResizeObserver(schedule);    if (floating.current) ro.observe(floating.current);    window.addEventListener("scroll", schedule, { capture: true, passive: true });    window.addEventListener("resize", schedule);    return () => {      cancelAnimationFrame(frame);      ro.disconnect();      window.removeEventListener("scroll", schedule, { capture: true });      window.removeEventListener("resize", schedule);    };  }, [open, anchor, point, floating, side, align, offset, padding]);  return open ? place : { ...place, ready: false };}/** Mount → show next frame → hide → unmount after `exit` ms. */export function usePresence(open: boolean, exit = 160) {  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  if (open && !mounted) setMounted(true);  React.useEffect(() => {    if (open) {      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      setMounted(false);      setShown(false);    }, exit);    return () => window.clearTimeout(timer);  }, [open, exit]);  return { mounted: mounted || open, shown: open && shown };}/** Escape and presses outside `refs` call onDismiss while `open`. */export function useDismiss(open: boolean, onDismiss: (reason: "escape" | "outside") => void, refs: React.RefObject<Element | null>[]) {  const latest = React.useRef({ onDismiss, refs });  React.useEffect(() => {    latest.current = { onDismiss, refs };  });  React.useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key !== "Escape") return;      e.stopPropagation();      latest.current.onDismiss("escape");    };    const onDown = (e: PointerEvent) => {      const target = e.target as Node;      if (latest.current.refs.some((r) => r.current?.contains(target))) return;      latest.current.onDismiss("outside");    };    document.addEventListener("keydown", onKey);    document.addEventListener("pointerdown", onDown, true);    return () => {      document.removeEventListener("keydown", onKey);      document.removeEventListener("pointerdown", onDown, true);    };  }, [open]);}/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */export function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = "cubic-bezier(0.16,1,0.3,1)"): React.CSSProperties {  const d = 6;  const shift = side === "top" ? `0 ${d}px` : side === "bottom" ? `0 -${d}px` : side === "left" ? `${d}px 0` : `-${d}px 0`;  return {    opacity: shown ? 1 : 0,    translate: shown || reduced ? "0 0" : shift,    scale: shown || reduced ? "1" : "0.96",    filter: shown || reduced ? "blur(0px)" : "blur(4px)",    transition: reduced      ? "opacity 120ms ease-out"      : shown        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,  };}

Registry item: /r/popover.json