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/popoverRequires 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 escapesoverflowandz-indexwherever it is in the DOM. Base UI's Positioner props are options ofpopup(), 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, anddisableAnchorTrackingare not supported. Collision handling flips to the opposite side and clamps to the viewport; an oversized popup gets an inlinemax-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 withhandle.context.get(popover.Context)(open,openReason,setOpen(open, { reason }),activeTrigger,side,align, …).open:boolean. Controlled open state.defaultOpen:boolean. Initial uncontrolled state. Defaults tofalse.onOpenChange(open, details): called before the state changes.detailshasreason,event,trigger, andcancel()(keeps the current state).onOpenChangeComplete(open): called after the enter or exit animation finished.modal:false(default) |true|'trap-focus'.truelocks page scroll, traps focus, makes everything outside the popup (and backdrop)inert, and setsaria-modal="true".'trap-focus'only traps focus.
trigger(options?): the toggle button. Setstype="button",aria-haspopup="dialog",aria-expanded,aria-controls, and nativedisabled. Non-<button>hosts getrole="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 to300.closeDelay:number. Hover-close delay in ms. Defaults to0.
popup(options?): the popup (Base UI's Positioner + Popup). Setsid,role="dialog",popover="manual",tabIndex={-1},aria-labelledby/aria-describedby(fromtitle()/description()),hiddenwhile 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/endfollow the text direction on the top and bottom sides.sideOffset:number | (data) => number. Gap between anchor and popup. Defaults to0.alignOffset:number | (data) => number. Shift along the alignment axis (away from the aligned edge). Defaults to0. 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 to5.arrowPadding:number. Minimum distance between the arrow and the popup edges. Defaults to5.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).falsedoesn't move focus; a string is a selector inside the popup.finalFocus: same shape. Defaults to the trigger.falseleaves focus alone.
arrow(): an element inside the popup that points at the anchor. Gets inlineposition: absoluteplusleft(top/bottom sides) ortop(left/right sides); offset it across the edge with CSS, e.g.data-[side=bottom]:top-[-6px]. Note the UA popover styleoverflow: autoclips an arrow placed outside the popup box: addoverflow: 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 popovermargin,border,padding,background). Pressing it is an outside press.title()/description(): label and describe the popup. Generated ids unless the host has its ownid.close(): a button inside the popup that closes it (reason'close-press').onPopoverChange(handler): listener mixin forPopoverChangeEvent(stems:popover-change, bubbles from the active trigger, hasopenandreason).
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 theContext(handleprop, read once) and to each detachedtrigger({ handle, payload })(read on the trigger's first render).Payloadtypespayloadin theContext's children function.- Detached triggers register with the handle, so they behave like triggers inside the
Context:aria-expanded,aria-controls, anddata-popup-openfollow 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
payloadis{ payload }in a children render function (re-run each time theContextrenders) andpayloadon the context value (handle.context.get(popover.Context).payload). It'sundefineduntil a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElementrejects function children).payloadalso works on triggers inside theContext. - Handle methods:
isOpen,open(triggerId?)(opens, or moves an open popup, to the trigger with thatid; reason'imperative-action'),close(). They act on the mountedContextand are ignored while none is mounted. A remountedContextstarts fresh. ContextpropstriggerId(controlled, pair withopen; storedetails.trigger?.idfromonOpenChange) anddefaultTriggerIdpick the active trigger by elementid.- 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 withoutaria-controls; it getsaria-controlsonce theContextmounts in the browser.
Behavior notes
- Opening: the popup renders
data-openanddata-starting-stylewithouthidden, thenshowPopover()runs, the popup is positioned against the trigger (stems-ui/anchor), the dismiss layer is registered, and focus moves in (initialFocus).data-starting-styleis removed on the next frame. - Closing:
data-closedanddata-ending-styleare 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 andhiddenis 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
hiddenin server HTML. With JavaScript disabled the popup stays hidden (it needsshowPopover()). Classes that setdisplay(e.g.flex) overridehiddenunless your CSS hides[hidden]with!important(Tailwind's preflight does). - Explicit host props win (
attrs()semantics). A custom popupidis supported; the triggers pick it up after the first render.
Styling hooks
| Part | Attribute | Values / notes |
|---|---|---|
| trigger | data-popup-open | present while open (on the trigger that opened it) |
| trigger | data-disabled | present when disabled |
| trigger | aria-expanded | "true" | "false" |
| popup | data-open | present when open |
| popup | data-closed | present when closed (also while animating out) |
| popup | data-side | final side: top | bottom | left | right | inline-* |
| popup | data-align | start | center | end |
| popup | data-starting-style | present on the first frame of opening |
| popup | data-ending-style | present while animating out |
| popup | hidden | present when closed and not animating |
| arrow | data-side | same as the popup |
| arrow | data-align | same as the popup |
| arrow | data-uncentered | present when the arrow can't point at the anchor's center |
| arrow | data-open | present when open |
| arrow | data-closed | present when closed |
| backdrop | data-open | present when open |
| backdrop | data-closed | present when closed |
| backdrop | data-starting-style | present on the first frame of opening |
| backdrop | data-ending-style | present while animating out |
| Part | CSS variable | Value |
|---|---|---|
| popup | --anchor-width | the anchor's width in px |
| popup | --anchor-height | the anchor's height in px |
| popup | --available-width | space available on the final side, in px |
| popup | --available-height | space available on the final side, in px |
| popup | --transform-origin | the anchor point relative to the popup (for scale/zoom-in) |