Popover

Popover is the anchored floating surface — the one you reach for when the user has a secondary decision to make next to the thing that triggered it. Sort pickers, action menus, account dropdowns, properties inspectors, hover cards, inline editors: anywhere a button or row should reveal extra controls without taking the user away from the page. Unlike Dialog, which centers on the viewport and blocks the page until the user responds, a popover anchors to its trigger via CSS anchor positioning, auto-flips when it would overflow, and dismisses on outside-click without inert-ing the rest of the page.

There are two ways to drive it. The compound APIPopoverRoot + PopoverTrigger + Popover (+ optional PopoverClose) — wires a popover to a specific trigger element in JSX. Controlled mode drives Popover directly via open / onOpenChange — reach for it when the open state is owned by your app, or when the trigger lives outside the React subtree (a canvas hit, a context-menu pointer position via anchorRect, a programmatic walkthrough).

The popover composes naturally with List and its row primitives — ListButton, ListTitle, ListSeparator, ListItem — which is how you build the dense option menus and action sheets that dominate real app UIs.

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Sort by</Button>
  </PopoverTrigger>
  <Popover className="w-56" offset={8}>
    <List>
      <ListTitle>Sort by</ListTitle>
      {OPTIONS.map((o) => (
        <ListButton
          key={o.id}
          size="md"
          selected={sort === o.id}
          onClick={() => setSort(o.id)}
          after={sort === o.id ? <CheckIcon /> : undefined}
        >
          {o.label}
        </ListButton>
      ))}
      <ListSeparator />
      <ListButton size="md" icon={<PlusIcon />}>
        Add custom sort
      </ListButton>
    </List>
  </Popover>
</PopoverRoot>

Usage

Compound API

import {
  Button,
  List,
  ListButton,
  ListTitle,
  Popover,
  PopoverRoot,
  PopoverTrigger,
} from '@cladd-ui/react';
 
<PopoverRoot>
  <PopoverTrigger>
    <Button>Sort by</Button>
  </PopoverTrigger>
  <Popover className="w-56" offset={8}>
    <List>
      <ListTitle>Sort by</ListTitle>
      <ListButton size="md">Last updated</ListButton>
      <ListButton size="md">Date created</ListButton>
      <ListButton size="md">Name</ListButton>
    </List>
  </Popover>
</PopoverRoot>;

PopoverRoot owns the open state (uncontrolled by default) and the anchor ref. PopoverTrigger clones its single child to register the element as the anchor and attach an onClick that toggles the root — point it at any Button, Chip, or other clickable element. Popover reads the root's open state and anchor ref automatically, then renders a portalled Surface anchored against the trigger via CSS anchor positioning.

Controlled

import { Button, Popover } from '@cladd-ui/react';
 
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement>(null);
 
<Button ref={anchorRef} onClick={() => setOpen((o) => !o)}>
  Open
</Button>
<Popover open={open} onOpenChange={setOpen} anchorRef={anchorRef}>
  ...
</Popover>;

Drop PopoverRoot/PopoverTrigger and drive Popover directly through open / onOpenChange. You also pass anchorRef yourself when there's no surrounding root. Use this when the popover represents state your app already owns (a hover-card opened from a keyboard event, an inline editor toggled from a save-conflict response), or when the trigger lives outside the React subtree — pass an anchorRect instead of anchorRef to anchor against a static pointer position (e.g. a right-click context menu).

Examples

Compound

The canonical shape — PopoverRoot wraps the trigger and the popover as siblings. The root owns the open state and the anchor ref, so trigger and popover stay in sync through context without prop-drilling. PopoverClose (used in this example) wraps any child you want to dismiss on click, the same way PopoverTrigger works for opening.

<PopoverRoot>
  <PopoverTrigger>
    <Button>Open popover</Button>
  </PopoverTrigger>
  <Popover className="w-64" offset={8}>
    <div className="flex flex-col gap-2 p-4">
      <SectionTitle>Compound API</SectionTitle>
      <p className="text-sm">
        Trigger, popover, and close all live inside PopoverRoot. The root
        owns the open state and the anchor ref.
      </p>
      <PopoverClose>
        <Button size="sm" className="self-end">
          Got it
        </Button>
      </PopoverClose>
    </div>
  </Popover>
</PopoverRoot>

Controlled

Pass open and onOpenChange directly to Popover to drive it from external state. The surrounding PopoverRoot is bypassed when open is provided, so you can drop it entirely — but you'll need to supply an anchorRef yourself for the popover to find its anchor. Use this when the popover tracks state your app already owns rather than a click on a specific button.

closed
<div className="flex items-center gap-2">
  <Button ref={anchorRef} onClick={() => setOpen((o) => !o)}>
    {open ? 'Close' : 'Open'} from external state
  </Button>
  <Chip color={open ? 'green' : 'neutral'}>
    {open ? 'open' : 'closed'}
  </Chip>
</div>
<Popover
  open={open}
  onOpenChange={setOpen}
  anchorRef={anchorRef}
  className="w-56"
  offset={8}
>
  <div className="flex flex-col gap-2 p-4">
    <SectionTitle>Controlled</SectionTitle>
    <p className="text-sm">
      open, onOpenChange, and anchorRef come from the surrounding
      component — no PopoverRoot needed.
    </p>
    <Button size="sm" className="self-end" onClick={() => setOpen(false)}>
      Close
    </Button>
  </div>
</Popover>

Context menu

A right-click handler is the canonical use case for anchorRect. On contextmenu, prevent the browser's native menu, snapshot the pointer position as a zero-size DOMRect (new DOMRect(e.clientX, e.clientY, 0, 0)), and pass it to Popover — the position-area system anchors against the rect the same way it would against a real DOM element. position="bottom-start" plus the built-in flip-block, flip-inline fallbacks reproduce native context-menu behavior, flipping at viewport edges so the menu always stays in bounds. Drop in a List of ListButtons and you're done.

Right-click anywhere in this area
<SurfaceCut
  onContextMenu={(e) => {
    e.preventDefault();
    setRect(new DOMRect(e.clientX, e.clientY, 0, 0));
    setOpen(true);
  }}
  className="w-full rounded-2xl"
  contentClassName="flex min-h-64 items-center justify-center select-none text-sm text-cladd-fg-softer"
>
  Right-click anywhere in this area
</SurfaceCut>
<Popover
  open={open}
  onOpenChange={setOpen}
  anchorRect={rect ?? undefined}
  position="bottom-start"
  className="fixed! w-56"
>
  <List>
    <ListButton
      icon={<CopyIcon />}
      after={<Shortcut size="sm">cmd c</Shortcut>}
    >
      Copy
    </ListButton>
    <ListButton
      icon={<PlusIcon />}
      after={<Shortcut size="sm">cmd d</Shortcut>}
    >
      Duplicate
    </ListButton>
    <ListButton icon={<EnvelopeIcon />}>Send to…</ListButton>
    <ListSeparator />
    <ListButton icon={<ArchiveIcon />}>Archive</ListButton>
    <ListButton color="red">Delete</ListButton>
  </List>
</Popover>

With list

List and its row primitives drop straight into a popover — this is the workhorse pattern for action menus, sort pickers, navigation dropdowns, and command palettes. Use ListTitle for section eyebrows, ListSeparator to group rows, and the after slot on ListButton for a right-aligned Shortcut hint or Chip. Drop ListButton's size to md (or sm) — the default lg is tuned for sidebars, not popover menus.

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Project actions</Button>
  </PopoverTrigger>
  <Popover className="w-64" offset={8}>
    <List>
      <ListTitle>Workspace</ListTitle>
      <ListButton
        size="md"
        icon={<EnvelopeIcon />}
        after={<Shortcut size="2xs">cmd 1</Shortcut>}
      >
        Inbox
      </ListButton>
      <ListButton
        size="md"
        icon={<NoteIcon />}
        after={<Shortcut size="2xs">cmd 2</Shortcut>}
      >
        Drafts
      </ListButton>
      <ListButton
        size="md"
        icon={<ArchiveIcon />}
        after={<Shortcut size="2xs">cmd 3</Shortcut>}
      >
        Archive
      </ListButton>
      <ListSeparator />
      <ListTitle>Actions</ListTitle>
      <ListButton
        size="md"
        icon={<CopyIcon />}
        after={<Shortcut size="2xs">cmd d</Shortcut>}
      >
        Duplicate
      </ListButton>
      <ListButton
        size="md"
        icon={<PlusIcon />}
        after={<Shortcut size="2xs">cmd n</Shortcut>}
      >
        New project
      </ListButton>
      <ListSeparator />
      <ListButton size="md" color="red">
        Delete project
      </ListButton>
    </List>
  </Popover>
</PopoverRoot>

Rich content

Not every popover is a list — children is any ReactNode, so you can drop in a SectionTitle, a stack of Switches, inline Inputs, or any combination. Workspace settings, quick filters, mini account panels — all live happily inside a popover.

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Workspace settings</Button>
  </PopoverTrigger>
  <Popover className="w-72 text-sm" offset={8}>
    <div className="flex flex-col gap-4 p-4">
      <div className="flex flex-col gap-1">
        <SectionTitle>Workspace</SectionTitle>
        <div className="flex items-center justify-between">
          <span className="font-medium">acme-marketing</span>
          <Chip color="brand">Pro</Chip>
        </div>
        <span className="text-cladd-fg-soft">
          8 members · 42 projects
        </span>
      </div>
      <SurfaceCut className="rounded-full" contentClassName="h-px" />
      <div className="flex flex-col gap-2">
        <SectionTitle>Notifications</SectionTitle>
        <label className="flex items-center justify-between gap-4">
          <span>Email updates</span>
          <Switch as="div" checked={notify} onChange={setNotify} />
        </label>
        <label className="flex items-center justify-between gap-4">
          <span>Mentions</span>
          <Switch as="div" checked={mentions} onChange={setMentions} />
        </label>
        <label className="flex items-center justify-between gap-4">
          <span>Weekly digest</span>
          <Switch as="div" checked={digest} onChange={setDigest} />
        </label>
      </div>
    </div>
  </Popover>
</PopoverRoot>

Custom width

The default Popover surface is w-40. Override it with a Tailwind width on className (w-56, w-64, w-72, w-96, or any arbitrary value). The popover caps at max-w-[calc(100vw-16px)] so it never overflows the viewport, and the inner content area scrolls vertically past max-h-[70vh] — adjust either via contentClassName when you need to.

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Open popover</Button>
  </PopoverTrigger>
  <Popover className="w-56" offset={8} closeOnBackdropClick={false}>
    <List>
      <ListTitle>Custom width</ListTitle>
      <ListItem>
        <span className="text-cladd-fg-soft">Width</span>
        <span className="ml-auto font-mono">w-56</span>
      </ListItem>
      <ListSeparator />
      <ListButton size="md" icon={<NoteIcon />}>
        The popover stretches to the className width
      </ListButton>
      <ListButton size="md" icon={<CheckIcon />}>
        Long rows wrap inside the surface
      </ListButton>
    </List>
  </Popover>
</PopoverRoot>

Position

position accepts twelve values — four sides (top, bottom, left, right) and three alignments per side (-start, center, -end), giving you fine-grained control over both which edge of the anchor the popover sits on and which corner of the anchor it lines up with. The bare-side values (top, bottom, left, right) center along the anchor's perpendicular axis. Default is 'bottom'. Position is a preference: a positionTryFallbacks of flip-block, flip-inline, flip-block flip-inline means the popover automatically flips to the opposite side when it would overflow the viewport.

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Anchor</Button>
  </PopoverTrigger>
  <Popover
    className="w-48"
    offset={8}
    position="bottom"
    closeOnBackdropClick={false}
  >
    <div className="flex flex-col gap-1 p-3">
      <SectionTitle>Position</SectionTitle>
      <span className="font-mono text-sm">bottom</span>
    </div>
  </Popover>
</PopoverRoot>

Offset

offset controls the gap between the anchor and the popover. A single number/string sets the main-axis spacing (push away from the anchor); a [main, cross] tuple also shifts the popover along the anchor's edge. Numbers are pixels; strings pass through (so '8px', '1rem', or '50%' all work — % resolves against anchor-size(width|height) depending on the position).

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Anchor</Button>
  </PopoverTrigger>
  <Popover className="w-48" offset={8} closeOnBackdropClick={false}>
    <div className="flex flex-col gap-1 p-3">
      <SectionTitle>Offset</SectionTitle>
      <span className="font-mono text-sm">8px</span>
    </div>
  </Popover>
</PopoverRoot>

Backdrop

backdrop adds a dimming layer behind the popover that captures clicks for dismiss. Use it for popovers that demand attention before the user does anything else — a quick-action confirm, a "name your file" prompt — where you want to lock the rest of the page out without the full Dialog treatment. backdropTransparent keeps the click-capturing layer but drops the visual dim — handy when you want easy outside-click dismiss without darkening the page.

<PopoverRoot>
  <PopoverTrigger>
    <Button>Open with backdrop</Button>
  </PopoverTrigger>
  <Popover
    className="w-64"
    offset={8}
    backdrop
    backdropTransparent={transparent}
  >
    <div className="flex flex-col gap-2 p-4">
      <SectionTitle>Backdrop</SectionTitle>
      <p className="text-sm">
        Click anywhere outside the popover to close it. The backdrop dims
        the page below — turn on transparent to keep the dismiss surface
        without the visual dim.
      </p>
    </div>
  </Popover>
</PopoverRoot>

Color

color sets the cladd-color-{name} token on the popover surface. It tints the gradient fill, the outline ring, and propagates to any color-aware children (Chips, ListButtons, Buttons set to color={undefined} inheriting from context). Keep it neutral for most menus; bring color in when the popover represents a decision tied to a specific accent (an error-tinted "what went wrong" popover, a brand-colored upsell).

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button color="brand">Open brand popover</Button>
  </PopoverTrigger>
  <Popover
    className="w-56"
    offset={8}
    color="brand"
    closeOnBackdropClick={false}
  >
    <div className="flex flex-col gap-2 p-4">
      <SectionTitle>Accent color</SectionTitle>
      <p className="text-sm">
        color tints the surface, ring, and any cladd-color-aware children
        like Chips.
      </p>
      <div className="flex gap-1">
        <Chip color="brand">brand</Chip>
        <Chip color="brand">chip</Chip>
      </div>
    </div>
  </Popover>
</PopoverRoot>

Surface variant

variant picks the Surface treatment of the popover shell. 'gradient' (default in dark theme) gives the slight angled-light look that reads as elevated; 'solid' is flatter and more utilitarian; the *-fill variants flood the surface with the accent — louder, harder to miss, right for popovers that need to read as a single semantic block.

<PopoverRoot defaultOpen>
  <PopoverTrigger>
    <Button>Show popover</Button>
  </PopoverTrigger>
  <Popover
    className="w-64"
    offset={8}
    variant="gradient"
    closeOnBackdropClick={false}
  >
    <div className="flex flex-col gap-2 p-4">
      <SectionTitle>Surface variant</SectionTitle>
      <p className="text-sm">
        The popover surface uses the "gradient" variant. Fill variants
        flood the surface with the accent.
      </p>
    </div>
  </Popover>
</PopoverRoot>

API Reference

Popover

Inherits from SurfaceProps
anchorRect: DOMRect | React.RefObject<DOMRect>Static rect (or ref to one) to anchor against when there's no DOM anchor element (e.g. for a context menu opened at a pointer position). Ignored if anchorRef.current exists.
anchorRef: React.RefObject<HTMLElement | null>Ref to the element the popover should anchor against. Defaults to the anchor registered by PopoverRoot + PopoverTrigger.

CSS anchor positioning is used - an anchor-name is auto-applied to the element if it doesn't already have one.
backdrop: booleanfalseRender a backdrop behind the popover. Default false.
backdropTransparent: booleanMake the backdrop transparent (still captures clicks for outside-close).
children: ReactNodePopover content.
className: stringExtra classes applied to the popover root Surface.
closeOnBackdropClick: booleantrueDefault true.
closeOnEscape: booleantrueDefault true. Suppressed automatically when this popover has a child popover/dialog open.
color: ColorAccent color token (Color enum). Sets the popover's cladd-color-{name} class - used by border/ring/text helpers.
contentClassName: stringExtra classes applied to the inner scrollable content area. Default includes max-h-[70vh] overflow-auto.
lazy: booleanSet to true when the popover is rendered inside a React lazy() + Suspense boundary so it opens on the next tick (after the lazy chunk has resolved and mounted).
offset: OffsetValue | [ OffsetValue, OffsetValue ]Spacing from anchor. Either a single value (main axis only) or [main, cross].

Numbers are pixels; strings pass through (e.g. '8px', '50%' - % resolves against anchor-size(width|height) depending on the position).
onClose: () => voidFires when the close transition begins (after open flips to false, before the animation).
onClosed: () => voidFires after the close transition completes - use for unmount/cleanup work tied to dismissal.
onOpen: () => voidFires when the open transition begins (after open flips to true, before the animation).
onOpenChange: (open: boolean) => voidFires whenever the open state should change. When omitted, falls back to the PopoverRoot setter.
onOpened: () => voidFires after the open transition completes (transitionend on the surface).
open: booleanControlled open state. When omitted, falls back to the surrounding PopoverRoot state, then false.
outline: booleantrueOutline ring on the popover surface. Default true for non-light themes.
position: PopoverPosition'bottom'Anchor side + alignment. See PopoverPosition. Default 'bottom'.
root: string | boolean'#app, #__next, #root'Portal target. CSS selector string (default '#app, #__next, #root' - first match wins), or false to render inline without portalling.
surfaceLevel: number | stringForwarded to the underlying Surface as level. Default depends on theme: 1 for light theme, undefined (parent + 1) for dark theme.
variant: SurfaceVariantSurface variant. Default depends on theme: 'gradient' for dark, 'solid' for light.
viewportMargin: number4Minimum gap (px) the popover should keep from the viewport edge when it would otherwise be clamped there. Default 4. Set to 0 to disable.

PopoverRoot

State container for the surrounding compound — owns the open state and the anchor ref, and provides both to PopoverTrigger, Popover, and PopoverClose through context.

children*: ReactNodeTrigger + popover (+ optional close) elements.
defaultOpen: booleanfalseInitial open state (uncontrolled). Default false. Ignored when open is provided.
onOpenChange: (open: boolean) => voidFires whenever the open state should change (clicks on trigger, outside-clicks, escape).
open: booleanControlled open state. When provided, internal state is bypassed.

PopoverTrigger

Clones its single child to attach (1) a ref callback that registers the element as the popover's anchor, and (2) an onClick handler that toggles the surrounding PopoverRoot's open state. Both are composed with any existing ref / onClick on the child, not replaced. No-ops (renders the child as-is) when used outside a PopoverRoot.

children*: ReactNodeSingle React element to use as the trigger. Must accept ref and onClick.

PopoverClose

Clones its single child to attach an onClick that flips the surrounding PopoverRoot's open state to false — the popover equivalent of DialogClose. Wrap any inner button or row that should dismiss the popover on click.

children*: ReactNodeSingle React element to use as the close affordance. Must accept onClick.