Stems UI
Menu

Popover

An accessible popup anchored to a button.

Last change: none

Detached triggers sharing one popover

Codeapp/demos/public/popover.tsx
import { clientEntry, type Handle } from 'remix/component'
import * as popover from 'stems-ui/popover'
import { buttonVariants } from '@stemsui/registry/ui/button.tsx'
import { Input } from '@stemsui/registry/ui/input.tsx'
import { Label } from '@stemsui/registry/ui/label.tsx'
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from '@stemsui/registry/ui/popover.tsx'

const dimensions = [
  { id: 'width', label: 'Width', value: '100%' },
  { id: 'max-width', label: 'Max. width', value: '300px' },
  { id: 'height', label: 'Height', value: '25px' },
  { id: 'max-height', label: 'Max. height', value: 'none' },
] as const

const sides = ['top', 'right', 'bottom', 'left'] as const

export const PopoverDemo = clientEntry(import.meta.url, function PopoverDemo(handle: Handle) {
  let lastChange = 'none'

  return () => (
    <div class="flex flex-col items-center gap-10">
      <Popover
        onOpenChange={(open, details) => {
          lastChange = `${open ? 'opened' : 'closed'} (${details.reason})`
          void handle.update()
        }}
      >
        <PopoverTrigger class={buttonVariants({ variant: 'outline' })} data-testid="dimensions">
          Open popover
        </PopoverTrigger>
        <PopoverContent class="w-80">
          <PopoverHeader>
            <PopoverTitle>Dimensions</PopoverTitle>
            <PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
          </PopoverHeader>
          <div class="grid gap-2">
            {dimensions.map((field) => (
              <div class="grid grid-cols-3 items-center gap-4">
                <Label for={`popover-demo-${field.id}`}>{field.label}</Label>
                <Input
                  id={`popover-demo-${field.id}`}
                  defaultValue={field.value}
                  class="col-span-2 h-8"
                />
              </div>
            ))}
          </div>
        </PopoverContent>
      </Popover>

      <div class="flex flex-wrap justify-center gap-2">
        {sides.map((side) => (
          <Popover>
            <PopoverTrigger
              class={buttonVariants({ variant: 'outline', size: 'sm' })}
              data-testid={`side-${side}`}
            >
              {side}
            </PopoverTrigger>
            <PopoverContent side={side} class="w-56">
              <PopoverHeader>
                <PopoverTitle>Side: {side}</PopoverTitle>
                <PopoverDescription>
                  Flips to the opposite side when there isn't room.
                </PopoverDescription>
              </PopoverHeader>
            </PopoverContent>
          </Popover>
        ))}
      </div>

      <Popover>
        <PopoverTrigger class={buttonVariants({ variant: 'secondary' })} data-testid="nested">
          Nested popovers
        </PopoverTrigger>
        <PopoverContent align="start" class="w-64">
          <PopoverHeader>
            <PopoverTitle>Workspace</PopoverTitle>
            <PopoverDescription>Escape closes the innermost popover first.</PopoverDescription>
          </PopoverHeader>
          <Popover>
            <PopoverTrigger
              class={buttonVariants({ variant: 'outline', size: 'sm' })}
              data-testid="nested-inner"
            >
              Members
            </PopoverTrigger>
            <PopoverContent side="right" align="start" class="w-56">
              <PopoverHeader>
                <PopoverTitle>Members</PopoverTitle>
                <PopoverDescription>3 people have access.</PopoverDescription>
              </PopoverHeader>
              <button type="button" class={buttonVariants({ size: 'sm' })}>
                Invite
              </button>
            </PopoverContent>
          </Popover>
        </PopoverContent>
      </Popover>

      <p class="text-sm text-muted-foreground" data-testid="popover-result">
        Last change: <span class="font-medium text-foreground">{lastChange}</span>
      </p>

      <DetachedTriggers />
    </div>
  )
})

interface Person {
  name: string
  role: string
  email: string
}

const people: Person[] = [
  { name: 'Ada Lovelace', role: 'Analyst', email: 'ada@example.com' },
  { name: 'Grace Hopper', role: 'Compiler lead', email: 'grace@example.com' },
  { name: 'Alan Turing', role: 'Researcher', email: 'alan@example.com' },
]

/**
 * Detached triggers: the triggers live outside the `Popover`, linked by a handle. Each passes a
 * `payload`; the one popover shows the active trigger's person and anchors to that trigger.
 */
function DetachedTriggers() {
  let people$ = popover.createHandle<Person>()

  return () => (
    <div class="flex flex-col items-center gap-3" data-testid="detached">
      <p class="text-sm text-muted-foreground">Detached triggers sharing one popover</p>
      <div class="flex flex-wrap justify-center gap-2">
        {people.map((person, index) => (
          <PopoverTrigger
            id={`detached-person-${index}`}
            class={buttonVariants({ variant: 'outline', size: 'sm' })}
            data-testid={`detached-${index}`}
            handle={people$}
            payload={person}
          >
            {person.name.split(' ')[0]}
          </PopoverTrigger>
        ))}
      </div>

      <Popover handle={people$}>
        {({ payload }) => (
          <PopoverContent class="w-64" data-testid="detached-content">
            <PopoverHeader>
              <PopoverTitle>{payload?.name ?? 'Someone'}</PopoverTitle>
              <PopoverDescription>{payload?.role}</PopoverDescription>
            </PopoverHeader>
            <a
              class={buttonVariants({ variant: 'secondary', size: 'sm' })}
              href={`mailto:${payload?.email ?? ''}`}
            >
              Email {payload?.name.split(' ')[0]}
            </a>
          </PopoverContent>
        )}
      </Popover>
    </div>
  )
}

Installation

npx shadcn add @stemsui/popover

Requires the one-time setup. Behavior: stems-ui/popover.

API reference

The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.

stems-ui/popover

popover wires a trigger button to an anchored popup that opens in the top layer, with dismissal, focus management, and enter/exit animation hooks. App code owns the markup and visual styles.

Base UI equivalent: Popover.

Usage

import * as popover from 'stems-ui/popover'

function Notifications() {
  return () => (
    <popover.Context>
      <button mix={popover.trigger()}>Notifications</button>
      <div
        class="w-72 rounded-md bg-white p-4 shadow-md transition-[opacity,scale] duration-150 origin-(--transform-origin) data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0"
        mix={popover.popup({ side: 'bottom', align: 'start', sideOffset: 8 })}
      >
        <h2 mix={popover.title()}>Notifications</h2>
        <p mix={popover.description()}>You are all caught up.</p>
        <button mix={popover.close()}>Close</button>
      </div>
    </popover.Context>
  )
}

Control the state with open and onOpenChange(open, details), or let the context own it with defaultOpen. Listen for bubbling changes on the trigger or an ancestor:

<div mix={popover.onPopoverChange((event) => console.log(event.open, event.reason))}>…</div>
Differences from Base UI
  • No Portal, no Positioner element. The popup is shown in the top layer with popover="manual" + showPopover(), so it escapes overflow and z-index wherever it is in the DOM. Base UI's Positioner props are options of popup(), and the positioner's data attributes and CSS variables are set on the popup element.
  • Render nested popovers inside their parent's popup (in the DOM), so presses and focus inside the child count as inside the parent.
  • --positioner-width / --positioner-height / --popup-width / --popup-height, the Viewport part, actionsRef, collisionAvoidance, collisionBoundary, sticky, positionMethod, and disableAnchorTracking are not supported. Collision handling flips to the opposite side and clamps to the viewport; an oversized popup gets an inline max-width / max-height.
  • The popup element always stays in the DOM (closed popups get hidden). The registry component decides whether to render children while closed.

stems-ui/popover

  • Context: provider that owns the open state. Read it in components with handle.context.get(popover.Context) (open, openReason, setOpen(open, { reason }), activeTrigger, side, align, …).
    • open: boolean. Controlled open state.
    • defaultOpen: boolean. Initial uncontrolled state. Defaults to false.
    • onOpenChange(open, details): called before the state changes. details has reason, event, trigger, and cancel() (keeps the current state).
    • onOpenChangeComplete(open): called after the enter or exit animation finished.
    • modal: false (default) | true | 'trap-focus'. true locks page scroll, traps focus, makes everything outside the popup (and backdrop) inert, and sets aria-modal="true". 'trap-focus' only traps focus.
  • trigger(options?): the toggle button. Sets type="button", aria-haspopup="dialog", aria-expanded, aria-controls, and native disabled. Non-<button> hosts get role="button", tabIndex, aria-disabled, and Enter/Space activation. Several triggers may share one popover: the popup anchors to (and returns focus to) the one that opened it, and pressing another trigger while open moves it there.
    • disabled: boolean.
    • openOnHover: boolean. Also open when hovered (reason 'trigger-hover'). A hover-opened popover closes when the pointer leaves the trigger and popup (moving toward the popup keeps it open), doesn't move focus, and stays open once the trigger is pressed.
    • delay: number. Hover-open delay in ms. Defaults to 300.
    • closeDelay: number. Hover-close delay in ms. Defaults to 0.
  • popup(options?): the popup (Base UI's Positioner + Popup). Sets id, role="dialog", popover="manual", tabIndex={-1}, aria-labelledby / aria-describedby (from title() / description()), hidden while closed, and the attributes and variables below.
    • side: 'top' | 'bottom' | 'left' | 'right' | 'inline-start' | 'inline-end'. Defaults to 'bottom'. Flips to the opposite side when it doesn't fit.
    • align: 'start' | 'center' | 'end'. Defaults to 'center'. start / end follow the text direction on the top and bottom sides.
    • sideOffset: number | (data) => number. Gap between anchor and popup. Defaults to 0.
    • alignOffset: number | (data) => number. Shift along the alignment axis (away from the aligned edge). Defaults to 0. Offset functions receive { side, align, anchor, positioner } sizes like Base UI's.
    • collisionPadding: number | { top, right, bottom, left }. Space kept from the viewport edges. Defaults to 5.
    • arrowPadding: number. Minimum distance between the arrow and the popup edges. Defaults to 5.
    • anchor: HTMLElement | { x, y, width?, height? } | () => …. Position against something other than the trigger.
    • initialFocus: boolean | string | HTMLElement | (interactionType) => …. Defaults to the first [autofocus] or tabbable element, else the popup itself (also when opened by touch). false doesn't move focus; a string is a selector inside the popup.
    • finalFocus: same shape. Defaults to the trigger. false leaves focus alone.
  • arrow(): an element inside the popup that points at the anchor. Gets inline position: absolute plus left (top/bottom sides) or top (left/right sides); offset it across the edge with CSS, e.g. data-[side=bottom]:top-[-6px]. Note the UA popover style overflow: auto clips an arrow placed outside the popup box: add overflow: visible.
  • backdrop(): an optional overlay, shown in the top layer just below the popup (popover="manual", role="presentation"). Style it to cover the viewport (fixed inset-0 size-full, and reset the UA popover margin, border, padding, background). Pressing it is an outside press.
  • title() / description(): label and describe the popup. Generated ids unless the host has its own id.
  • close(): a button inside the popup that closes it (reason 'close-press').
  • onPopoverChange(handler): listener mixin for PopoverChangeEvent (stems:popover-change, bubbles from the active trigger, has open and reason).
Change reasons

'trigger-press', 'trigger-hover', 'outside-press', 'escape-key', 'close-press', 'focus-out', and 'none' (state changed through the controlled open prop).

Detached triggers

Base UI's createHandle(): triggers can live anywhere in the document, outside the Context, linked by a handle. Each trigger can pass a payload, so one popup can serve many triggers with different data.

import * as popover from 'stems-ui/popover'

let people = popover.createHandle<{ name: string }>()

// Anywhere, e.g. in a table row:
<button mix={popover.trigger({ handle: people, payload: { name: 'Ada' } })}>Ada</button>
<button mix={popover.trigger({ handle: people, payload: { name: 'Grace' } })}>Grace</button>

// Elsewhere:
<popover.Context handle={people}>
  {({ payload }) => <div mix={popover.popup()}>{payload?.name}</div>}
</popover.Context>
  • createHandle<Payload>() returns the handle. Pass it to the Context (handle prop, read once) and to each detached trigger({ handle, payload }) (read on the trigger's first render). Payload types payload in the Context's children function.
  • Detached triggers register with the handle, so they behave like triggers inside the Context: aria-expanded, aria-controls, and data-popup-open follow the popup, the popup anchors to the active trigger, focus returns to it, and a press on another trigger counts as inside (it moves the popup instead of dismissing it).
  • The active trigger's payload is { payload } in a children render function (re-run each time the Context renders) and payload on the context value (handle.context.get(popover.Context).payload). It's undefined until a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElement rejects function children). payload also works on triggers inside the Context.
  • Handle methods: isOpen, open(triggerId?) (opens, or moves an open popup, to the trigger with that id; reason 'imperative-action'), close(). They act on the mounted Context and are ignored while none is mounted. A remounted Context starts fresh.
  • Context props triggerId (controlled, pair with open; store details.trigger?.id from onOpenChange) and defaultTriggerId pick the active trigger by element id.
  • Detached triggers aren't re-rendered by the Context; they update their state attributes directly when it renders. On the server nothing is shared (a handle is usually a module-level singleton), so a detached trigger renders closed and without aria-controls; it gets aria-controls once the Context mounts in the browser.

Behavior notes

  • Opening: the popup renders data-open and data-starting-style without hidden, then showPopover() runs, the popup is positioned against the trigger (stems-ui/anchor), the dismiss layer is registered, and focus moves in (initialFocus). data-starting-style is removed on the next frame.
  • Closing: data-closed and data-ending-style are rendered, the dismiss layer and modal state are released, and focus is restored right away. Once the popup's CSS transitions and animations finish, hidePopover() runs and hidden is rendered. Reopening mid-close reverses.
  • Dismissal uses the shared dismiss-layer stack: Escape closes only the topmost popup; an outside press closes every popup it is outside of (a press inside a parent closes its children). The triggers count as inside, so pressing the trigger toggles.
  • Focus-out: a non-modal popover closes (reason 'focus-out') when keyboard focus moves outside the popup and its triggers. Focus moved by a pointer press counts as an outside press instead.
  • Focus restore: on close, focus returns to the trigger (or finalFocus) if it was inside the popup or was lost to <body>, unless the popup closed through focus-out or an outside press on another focusable element.
  • Keyboard: Enter / Space on the trigger toggle; Escape closes; Tab moves through the popup's content in DOM order (with modal, Tab and Shift+Tab wrap inside the popup).
  • Closed popups get hidden in server HTML. With JavaScript disabled the popup stays hidden (it needs showPopover()). Classes that set display (e.g. flex) override hidden unless your CSS hides [hidden] with !important (Tailwind's preflight does).
  • Explicit host props win (attrs() semantics). A custom popup id is supported; the triggers pick it up after the first render.

Styling hooks

PartAttributeValues / notes
triggerdata-popup-openpresent while open (on the trigger that opened it)
triggerdata-disabledpresent when disabled
triggeraria-expanded"true" | "false"
popupdata-openpresent when open
popupdata-closedpresent when closed (also while animating out)
popupdata-sidefinal side: top | bottom | left | right | inline-*
popupdata-alignstart | center | end
popupdata-starting-stylepresent on the first frame of opening
popupdata-ending-stylepresent while animating out
popuphiddenpresent when closed and not animating
arrowdata-sidesame as the popup
arrowdata-alignsame as the popup
arrowdata-uncenteredpresent when the arrow can't point at the anchor's center
arrowdata-openpresent when open
arrowdata-closedpresent when closed
backdropdata-openpresent when open
backdropdata-closedpresent when closed
backdropdata-starting-stylepresent on the first frame of opening
backdropdata-ending-stylepresent while animating out
PartCSS variableValue
popup--anchor-widththe anchor's width in px
popup--anchor-heightthe anchor's height in px
popup--available-widthspace available on the final side, in px
popup--available-heightspace available on the final side, in px
popup--transform-originthe anchor point relative to the popup (for scale/zoom-in)