Popover

An anchored panel that floats beside its trigger, built on Base UI's popover primitive.

Import

import { Popover } from '@oztix/roadie-components/popover'

Examples

Default

A trigger opens a positioned panel. Popover.Content collapses the underlying Portal, Positioner, and Popup into a single element, and Popover.Arrow renders a pointer connecting the panel to its trigger.

<Popover>
  <Popover.Trigger render={<Button>Open popover</Button>} />
  <Popover.Content>
    <Popover.Arrow />
    <Popover.Header>
      <Popover.Title>Notifications</Popover.Title>
      <Popover.Description>You have no new notifications.</Popover.Description>
    </Popover.Header>
  </Popover.Content>
</Popover>

Intents

Portaled popups sit outside the trigger's CSS cascade, so set the colour context directly on Popover.Content with the intent prop. Each intent fits a different message — info for inline help, success for confirmation, warning and danger for caution and destructive actions, and accent to highlight something new.

function IntentExamples() {
  const [copied, setCopied] = useState(false)

  function copyLink(open) {
    setCopied(open)
    if (open) {
      navigator.clipboard?.writeText('https://oztix.com.au/event/123')
      setTimeout(() => setCopied(false), 2000)
    }
  }

  return (
    <div className='flex flex-wrap items-start gap-3'>
      {/* Info — inline help next to a field */}
      <Popover>
        <Popover.Trigger render={<IconButton aria-label='About API keys' emphasis='subtle' size='sm'><Info weight='bold' /></IconButton>} />
        <Popover.Content intent='info' positionerProps={{ side: 'right' }}>
          <Popover.Arrow />
          <Popover.Body>
            <p className='text-sm'>Your API key authenticates requests. Keep it secret — anyone with it can act on your behalf.</p>
          </Popover.Body>
        </Popover.Content>
      </Popover>

      {/* Success — copy a link, confirm, then auto-dismiss after 2s */}
      <Popover open={copied} onOpenChange={copyLink}>
        <Popover.Trigger render={<IconButton aria-label='Copy link'>{copied ? <Check weight='bold' /> : <LinkSimple weight='bold' />}</IconButton>} />
        <Popover.Content intent='success' positionerProps={{ side: 'top' }}>
          <Popover.Arrow />
          <Popover.Body>
            <p className='text-sm'>Link copied to your clipboard.</p>
          </Popover.Body>
        </Popover.Content>
      </Popover>

      {/* Warning — caution before leaving */}
      <Popover>
        <Popover.Trigger render={<Button>Leave page</Button>} />
        <Popover.Content intent='warning'>
          <Popover.Arrow />
          <Popover.Header>
            <Popover.Title>Unsaved changes</Popover.Title>
            <Popover.Description>Your edits will be lost if you leave now.</Popover.Description>
          </Popover.Header>
          <Popover.Footer>
            <Popover.Close render={<Button size='sm'>Stay</Button>} />
            <Popover.Close render={<Button intent='warning' emphasis='strong' size='sm'>Leave</Button>} />
          </Popover.Footer>
        </Popover.Content>
      </Popover>

      {/* Danger — destructive confirm on an icon button */}
      <Popover>
        <Popover.Trigger render={<IconButton aria-label='Delete file' intent='danger'><Trash weight='bold' /></IconButton>} />
        <Popover.Content intent='danger'>
          <Popover.Arrow />
          <Popover.Header>
            <Popover.Title>Delete file?</Popover.Title>
            <Popover.Description>This cannot be undone.</Popover.Description>
          </Popover.Header>
          <Popover.Footer>
            <Popover.Close render={<Button size='sm'>Cancel</Button>} />
            <Popover.Close render={<Button intent='danger' emphasis='strong' size='sm'>Delete</Button>} />
          </Popover.Footer>
        </Popover.Content>
      </Popover>

      {/* Accent — highlight a new feature */}
      <Popover>
        <Popover.Trigger render={<Button intent='accent' emphasis='strong'>What's new</Button>} />
        <Popover.Content intent='accent'>
          <Popover.Arrow />
          <Popover.Header>
            <Popover.Title>Saved views</Popover.Title>
            <Popover.Description>Pin your favourite filters and switch between them in one click.</Popover.Description>
          </Popover.Header>
          <Popover.Footer>
            <Popover.Close render={<Button size='sm'>Not now</Button>} />
            <Button intent='accent' emphasis='strong' size='sm'>Create a view</Button>
          </Popover.Footer>
        </Popover.Content>
      </Popover>
    </div>
  )
}

render(<IntentExamples />)

Position

A popover opens below its trigger by default and re-flips to stay in view. Set positionerProps on Popover.Content to control placement — side (top/right/bottom/left), align (start/center/end), and sideOffset.

<div className='flex flex-wrap gap-3'>
  <Popover>
    <Popover.Trigger render={<Button>Filters</Button>} />
    <Popover.Content positionerProps={{ side: 'bottom', align: 'start' }}>
      <Popover.Arrow />
      <Popover.Body>
        <p className='text-sm'>Refine results by date, venue, and status.</p>
      </Popover.Body>
    </Popover.Content>
  </Popover>
  <Popover>
    <Popover.Trigger render={<IconButton aria-label='Help' emphasis='subtle' size='sm'><Info weight='bold' /></IconButton>} />
    <Popover.Content positionerProps={{ side: 'right' }}>
      <Popover.Arrow />
      <Popover.Body>
        <p className='text-sm'>We use this to send order confirmations.</p>
      </Popover.Body>
    </Popover.Content>
  </Popover>
  <Popover>
    <Popover.Trigger render={<Button>Add reaction</Button>} />
    <Popover.Content positionerProps={{ side: 'top' }}>
      <Popover.Arrow />
      <Popover.Body>
        <p className='text-sm'>Pick an emoji to react.</p>
      </Popover.Body>
    </Popover.Content>
  </Popover>
</div>

Hover

Set openOnHover on Popover.Trigger (with an optional delay) so the panel opens on hover as well as click — ideal for previews and inline help. It still opens on focus and click, so keyboard users aren't locked out.

<Popover>
  <Popover.Trigger openOnHover delay={150} render={<Button>@jordan</Button>} />
  <Popover.Content positionerProps={{ side: 'top' }}>
    <Popover.Arrow />
    <Popover.Header>
      <Popover.Title>Jordan Lee</Popover.Title>
      <Popover.Description>Product designer · joined 2023</Popover.Description>
    </Popover.Header>
  </Popover.Content>
</Popover>

Composition

Popover is a compound component. Use Popover.Header, Popover.Body, and Popover.Footer to structure the panel, and Popover.Close to dismiss it.

<Popover>
  <Popover.Trigger render={<Button>Account</Button>} />
  <Popover.Content>
    <Popover.Arrow />
    <Popover.Header>
      <Popover.Title>Signed in</Popover.Title>
      <Popover.Description>you@example.com</Popover.Description>
    </Popover.Header>
    <Popover.Body>
      <p className='text-sm text-subtle'>Manage your profile and settings.</p>
    </Popover.Body>
    <Popover.Footer>
      <Popover.Close render={<Button size='sm'>Cancel</Button>} />
      <Button intent='accent' emphasis='strong' size='sm'>Manage account</Button>
    </Popover.Footer>
  </Popover.Content>
</Popover>

A Popover.Footer holds a centered actions row — ideal for a lightweight confirm-deletion flow anchored to the trigger.

<Popover>
  <Popover.Trigger render={<Button intent='danger' emphasis='strong'>Delete item</Button>} />
  <Popover.Content intent='danger'>
    <Popover.Arrow />
    <Popover.Header>
      <Popover.Title>Delete item?</Popover.Title>
      <Popover.Description>This action cannot be undone.</Popover.Description>
    </Popover.Header>
    <Popover.Footer>
      <Popover.Close render={<Button size='sm'>Cancel</Button>} />
      <Popover.Close render={<Button intent='danger' emphasis='strong' size='sm'>Delete</Button>} />
    </Popover.Footer>
  </Popover.Content>
</Popover>

Guidelines

Use a popover for contextual content anchored to a control — menus, quick forms, and confirmations. It has no size prop, sizing to its content and anchor, and you set intent on Popover.Content, not the trigger, since portaled popups do not inherit the trigger's CSS cascade.

Name buttons by their action

Do

Label the confirming button with the specific verb and object the user is committing to. A clear action like "Save changes" lets people act without rereading the title, and for destructive flows naming the consequence — "Delete event" — leaves no doubt about what happens.

Don’t

Vague labels like "OK", "Yes", or "Submit" tell the user nothing on their own. They have to jump back to the title to work out what they are actually agreeing to.

Write a title that states the decision

Delete event?

Do

A title that names the affected object and the action — a clear question or noun phrase — tells the user exactly what is at stake before they read another word.

Are you sure?

Don’t

Vague titles that never name the affected object force the user to hunt through the body for context, and make them hesitate before acting.

Use one prominent action, the rest normal

Do

Keep popover actions size='sm', with one prominent emphasis='strong' accent button (or danger for a destructive confirm) and the rest normal or default. In a compact, anchored panel the primary path needs to read instantly.

Don’t

Never use emphasis='subtle' for footer actions — a barely-there button reads as disabled in a tight panel and the prominent path loses its contrast.

Reach for a dialog when the task blocks

Do

Use a popover for contextual content anchored to a control — menus, quick confirms, and lightweight panels that sit beside the trigger without taking over the page.

Don’t

Don't use a popover for tasks that demand focus or block the page — those need a backdrop and trapped focus, so use a Dialog instead.

Anchor to the side that fits the action

Do

Place the panel where it relates to the trigger — inline help to the right of a field, a brief confirmation top of the button that fired it — and let it re-flip to stay in view.

Don’t

Don't force a side that covers the trigger or the content the popover refers to, or that pushes the panel off-screen on smaller viewports.

Reserve hover for low-stakes content

Do

Use openOnHover for previews and inline help that carry no decision — profile cards, definitions, field hints. It still opens on focus and click, so keyboard users aren't excluded.

Don’t

Don't put confirmations or footer actions behind hover. A destructive or committing action needs a deliberate click, not a pointer that happens to pass over the trigger.

Accessibility

  • Keyboard: Enter or Space on the trigger opens the popover; Escape closes it and returns focus to the trigger.
  • Default action: while the popover is open, Enter activates the strong primary action (the emphasis='strong' button), like a form's default submit. It is ignored while focus is on another button, a multi-line textarea, or editable content.
  • Focus: Focus moves into the popup when opened and is restored to the trigger on close.
  • ARIA: The trigger and popup are wired together via Base UI, and Popover.Title/Popover.Description are announced as the accessible name and description.
  • Icon-only close: When the dismiss control shows only an icon, give it an explicit label — e.g. <Popover.Close render={<IconButton aria-label='Close'><XIcon weight='bold' /></IconButton>} /> — so screen readers announce its purpose.

API reference

Popover

defaultOpenboolean

Whether the popover is initially open. To render a controlled popover, use the `open` prop instead.

Defaults to false.

openboolean

Whether the popover is currently open.

onOpenChange((open: boolean, eventDetails: PopoverRootChangeEventDetails) => void)

Event handler called when the popover is opened or closed.

onOpenChangeComplete((open: boolean) => void)

Event handler called after any animations complete when the popover is opened or closed.

actionsRefRefObject<PopoverRootActions | null>

A ref to imperative actions. - `unmount`: When specified, the popover will not be unmounted when closed. Instead, the `unmount` function must be called to unmount the popover manually. Useful when the popover's animation is controlled by an external library. - `close`: Closes the dialog imperatively when called.

modalboolean | "trap-focus"

Determines if the popover enters a modal state when open. - `true`: user interaction is limited to the popover: document page scroll is locked, and pointer interactions on outside elements are disabled. - `false`: user interaction with the rest of the document is allowed. - `'trap-focus'`: focus is trapped inside the popover, but document page scroll is not locked and pointer interactions outside of it remain enabled. When `modal` is `true`, focus trapping is enabled only if `<Popover.Close>` is rendered inside `<Popover.Popup>`. It can be visually hidden with your own CSS if needed, such as Tailwind's `sr-only` utility. When `modal` is `'trap-focus'`, render `<Popover.Close>` inside `<Popover.Popup>` so touch screen readers can escape the popup.

Defaults to false.

triggerIdstring | null

ID of the trigger that the popover is associated with. This is useful in conjunction with the `open` prop to create a controlled popover. There's no need to specify this prop when the popover is uncontrolled (i.e. when the `open` prop is not set).

defaultTriggerIdstring | null

ID of the trigger that the popover is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open popover.

handlePopoverHandle<unknown>

A handle to associate the popover with a trigger. If specified, allows external triggers to control the popover's open state.

childrenReactNode | PayloadChildRenderFunction<unknown>

The content of the popover. This can be a regular React node or a render function that receives the `payload` of the active trigger.

Popover.Arrow

No additional props — forwards all standard HTML attributes to the underlying element.

Popover.Body

No additional props — forwards all standard HTML attributes to the underlying element.

Popover.Close

Inherited from NativeButtonProps

nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (e.g. `<div>`).

Defaults to true.

Popover.Content

intent"neutral" | "brand" | "brand-secondary" | "accent" | "danger" | "success" | "warning" | "info" | null
positionerPropsPopoverPositionerProps

Props forwarded to the underlying Positioner (side, align, sideOffset).

Inherited from PopoverPopupProps

initialFocusboolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)

Determines the element to focus when the popover is opened. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (first tabbable element or popup). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing.

finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)

Determines the element to focus when the popover is closed. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing.

Popover.Description

No additional props — forwards all standard HTML attributes to the underlying element.

Popover.Footer

No additional props — forwards all standard HTML attributes to the underlying element.

Popover.Header

No additional props — forwards all standard HTML attributes to the underlying element.

Popover.Popup

intent"neutral" | "brand" | "brand-secondary" | "accent" | "danger" | "success" | "warning" | "info" | null

Inherited from PopoverPopupProps

initialFocusboolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)

Determines the element to focus when the popover is opened. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (first tabbable element or popup). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing.

finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)

Determines the element to focus when the popover is closed. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`). Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing.

Popover.Portal

Inherited from PopoverPortalProps

keepMountedboolean

Whether to keep the portal mounted in the DOM while the popup is hidden.

Defaults to false.

Inherited from Props

containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null

A parent element to render the portal element into.

Popover.Positioner

Inherited from UseAnchorPositioningSharedParameters

anchorany

An element to position the popup against. By default, the popup will be positioned against the trigger.

positionMethod"absolute" | "fixed"

Determines which CSS `position` property to use.

Defaults to 'absolute'.

side"top" | "bottom" | "left" | "right" | "inline-end" | "inline-start"

Which side of the anchor element to align the popup against. May automatically change to avoid collisions.

Defaults to 'bottom'.

sideOffsetnumber | OffsetFunction

Distance between the anchor and the popup in pixels. Also accepts a function that returns the distance to read the dimensions of the anchor and positioner elements, along with its side and alignment. The function takes a `data` object parameter with the following properties: - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. - `data.positioner`: the dimensions of the positioner element with properties `width` and `height`. - `data.side`: which side of the anchor element the positioner is aligned against. - `data.align`: how the positioner is aligned relative to the specified side. @example ```jsx <Positioner sideOffset={({ side, align, anchor, positioner }) => { return side === 'top' || side === 'bottom' ? anchor.height : anchor.width; }} /> ```

Defaults to 8.

align"center" | "start" | "end"

How to align the popup relative to the specified side.

Defaults to 'center'.

alignOffsetnumber | OffsetFunction

Additional offset along the alignment axis in pixels. Also accepts a function that returns the offset to read the dimensions of the anchor and positioner elements, along with its side and alignment. The function takes a `data` object parameter with the following properties: - `data.anchor`: the dimensions of the anchor element with properties `width` and `height`. - `data.positioner`: the dimensions of the positioner element with properties `width` and `height`. - `data.side`: which side of the anchor element the positioner is aligned against. - `data.align`: how the positioner is aligned relative to the specified side. @example ```jsx <Positioner alignOffset={({ side, align, anchor, positioner }) => { return side === 'top' || side === 'bottom' ? anchor.width : anchor.height; }} /> ```

Defaults to 0.

collisionBoundaryany

An element or a rectangle that delimits the area that the popup is confined to.

Defaults to 'clipping-ancestors'.

collisionPaddingany

Additional space to maintain from the edge of the collision boundary.

Defaults to 5.

stickyboolean

Whether to maintain the popup in the viewport after the anchor element was scrolled out of view.

Defaults to false.

arrowPaddingnumber

Minimum distance to maintain between the arrow and the edges of the popup. Use it to prevent the arrow element from hanging out of the rounded corners of a popup.

Defaults to 5.

disableAnchorTrackingboolean

Whether to disable the popup from tracking any layout shift of its positioning anchor.

Defaults to false.

collisionAvoidanceCollisionAvoidance

Determines how to handle collisions when positioning the popup. `side` controls overflow on the preferred placement axis (`top`/`bottom` or `left`/`right`): - `'flip'`: keep the requested side when it fits; otherwise try the opposite side (`top` and `bottom`, or `left` and `right`). - `'shift'`: never change side; keep the requested side and move the popup within the clipping boundary so it stays visible. - `'none'`: do not correct side-axis overflow. `align` controls overflow on the alignment axis (`start`/`center`/`end`): - `'flip'`: keep side, but swap `start` and `end` when the requested alignment overflows. - `'shift'`: keep side and requested alignment, then nudge the popup along the alignment axis to fit. - `'none'`: do not correct alignment-axis overflow. `fallbackAxisSide` controls fallback behavior on the perpendicular axis when the preferred axis cannot fit: - `'start'`: allow perpendicular fallback and try the logical start side first (`top` before `bottom`, or `left` before `right` in LTR). - `'end'`: allow perpendicular fallback and try the logical end side first (`bottom` before `top`, or `right` before `left` in LTR). - `'none'`: do not fallback to the perpendicular axis. When `side` is `'shift'`, explicitly setting `align` only supports `'shift'` or `'none'`. If `align` is omitted, it defaults to `'flip'`. @example ```jsx <Positioner collisionAvoidance={{ side: 'shift', align: 'shift', fallbackAxisSide: 'none', }} /> ```

Popover.Title

No additional props — forwards all standard HTML attributes to the underlying element.

Popover.Trigger

Inherited from NativeButtonProps

nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (e.g. `<div>`).

Defaults to true.