# Popover

> An anchored panel that floats beside its trigger

Status: stable. Added in Components 2.7.0. Updated in Components 2.16.0, 20 Oct 2026.

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

## Import

```tsx
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.

```tsx
<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
- `accent` to highlight something new

```tsx
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 for 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' 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 copies a link, confirms, then auto-dismisses 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' side='top'>
          <Popover.Arrow />
          <Popover.Body>
            <p className='text-sm'>Link copied to your clipboard.</p>
          </Popover.Body>
        </Popover.Content>
      </Popover>

      {/* Warning for 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 for a 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 to 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 these props on `Popover.Content` to control placement:

- `side` (`top`/`right`/`bottom`/`left`)
- `align` (`start`/`center`/`end`)
- `sideOffset`, the gap from the trigger in px
- `alignOffset`, the shift along the trigger's edge in px

```tsx
<Popover>
  <Popover.Trigger render={<Button>Filters</Button>} />
  <Popover.Content 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 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 side='top'>
    <Popover.Arrow />
    <Popover.Body>
      <p className='text-sm'>Pick an emoji to react.</p>
    </Popover.Body>
  </Popover.Content>
</Popover>
```

### Hover

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

```tsx
<Popover>
  <Popover.Trigger openOnHover delay={150} render={<Button>@jordan</Button>} />
  <Popover.Content 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.

```tsx
<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>
```

### With footer actions

A `Popover.Footer` holds a centered actions row. It suits a lightweight confirm-deletion flow anchored to the trigger.

```tsx
<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, such as menus, quick forms, and confirmations. It has no `size` prop and sizes to its content and anchor. 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**

```tsx
<Button intent='accent' emphasis='strong' size='sm'>Save changes</Button>
```

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. For destructive flows, naming the consequence, as in "Delete event", leaves no doubt about what happens.

**Don’t**

```tsx
<Button size='sm'>OK</Button>
```

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

**Do**

```tsx
<p className='text-display-ui-4 text-strong'>Delete event?</p>
```

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

**Don’t**

```tsx
<p className='text-display-ui-4 text-strong'>Are you sure?</p>
```

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**

```tsx
<><Button size='sm'>Cancel</Button><Button intent='accent' emphasis='strong' size='sm'>Save</Button></>
```

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**

```tsx
<><Button emphasis='subtle' size='sm'>Cancel</Button><Button intent='accent' emphasis='strong' size='sm'>Save</Button></>
```

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. That covers 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](https://ticketsolutionsptyltd.github.io/roadie/components/dialog.md) instead.

### Anchor to the side that fits the action

**Do**

Place the panel where it relates to the trigger. Put inline help to the `right` of a field, and a brief confirmation `top` of the button that fired it. 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, such as profile cards, definitions, and 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 so screen readers announce its purpose. For example, `<Popover.Close render={<IconButton aria-label='Close'><XIcon weight='bold' /></IconButton>} />`.

## API reference

### Popover

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

- `defaultOpen`: `boolean`. Defaults to `false`. Whether the popover is initially open. To render a controlled popover, use the `open` prop instead.
- `open`: `boolean`. 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.
- `actionsRef`: `RefObject<PopoverRootActions | null>`. A ref to imperative actions. - `unmount`: Ends the closing phase of the popover after an externally controlled closing animation finishes. Call `preventUnmountOnClose()` in `onOpenChange` first, otherwise the popover completes closing on its own. Whether it leaves the DOM is decided by `keepMounted` on the portal. - `close`: Closes the popover imperatively when called.
- `modal`: `boolean | "trap-focus"`. Defaults to `false`. 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. On touch devices, a `true` modal blocks outside taps but leaves the page scrollable unless the popup spans nearly the full viewport width, matching native iOS behavior. 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.
- `triggerId`: `string | 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 (that is, when the `open` prop is not set).
- `defaultTriggerId`: `string | 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.
- `handle`: `PopoverHandle<unknown>`. A handle to associate the popover with a trigger. If specified, allows external triggers to control the popover's open state.
- `children`: `ReactNode | 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

- `className`: `string | ((state: PopoverArrowState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Popover.Body

No props beyond the standard HTML attributes.

### Popover.Close

- `nativeButton`: `boolean`. Defaults to `true`. 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 (for example, `<div>`).
- `className`: `string | ((state: PopoverCloseState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Popover.Content

- `initialFocus`: `boolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)`. Determines the element to focus when the popover is opened. By default, focus moves to the first tabbable element inside the popup, except when the popover is opened by touch — then the popup itself is focused to avoid opening the virtual keyboard. - `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, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing.
- `finalFocus`: `boolean | 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, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing.
- `className`: `string | ((state: PopoverPopupState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.
- `intent`: `"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info" | null`.
- `side`: `"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"`. Defaults to `'bottom'`.
- `align`: `"center" | "start" | "end"`. Defaults to `'center'`.
- `sideOffset`: `number`. Defaults to `8`. Gap between trigger and popover, in px.
- `alignOffset`: `number`. Defaults to `0`. Shift along the trigger's edge, in px.
- `positionerProps`: `(Omit<PopoverPositionerProps, PlacementKey> & { side?: Side; align?: Align; sideOffset?: number | ... 1 more ... | undefined; alignOffset?: number | ... 1 more ... | undefined; })`. Props forwarded to the underlying Positioner.

### Popover.Description

- `className`: `string | ((state: PopoverDescriptionState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Popover.Footer

No props beyond the standard HTML attributes.

### Popover.Header

No props beyond the standard HTML attributes.

### Popover.Popup

- `initialFocus`: `boolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)`. Determines the element to focus when the popover is opened. By default, focus moves to the first tabbable element inside the popup, except when the popover is opened by touch — then the popup itself is focused to avoid opening the virtual keyboard. - `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, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing.
- `finalFocus`: `boolean | 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, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing.
- `className`: `string | ((state: PopoverPopupState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.
- `intent`: `"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info" | null`.

### Popover.Portal

- `keepMounted`: `boolean`. Defaults to `false`. Whether to keep the portal mounted in the DOM while the popup is hidden.
- `container`: `HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null`. A parent element to render the portal element into.
- `className`: `string | ((state: PopoverPortalState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Popover.Positioner

- `anchor`: `Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null`. An element to position the popup against. By default, the popup will be positioned against the trigger.
- `positionMethod`: `"absolute" | "fixed"`. Defaults to `'absolute'`. Determines which CSS `position` property to use.
- `side`: `"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"`. Defaults to `'bottom'`. Which side of the anchor element to align the popup against. May automatically change to avoid collisions.
- `sideOffset`: `number | OffsetFunction`. Defaults to `8`. 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; }} /> `
- `align`: `"center" | "start" | "end"`. Defaults to `'center'`. How to align the popup relative to the specified side.
- `alignOffset`: `number | OffsetFunction`. Defaults to `0`. 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; }} /> `
- `collisionBoundary`: `Boundary`. Defaults to `'clipping-ancestors'`. An element or a rectangle that delimits the area that the popup is confined to.
- `collisionPadding`: `Padding`. Defaults to `5`. Additional space to maintain from the edge of the collision boundary.
- `sticky`: `boolean`. Defaults to `false`. Whether to maintain the popup in the viewport after the anchor element was scrolled out of view.
- `arrowPadding`: `number`. Defaults to `5`. 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.
- `disableAnchorTracking`: `boolean`. Defaults to `false`. Whether to disable the popup from tracking any layout shift of its positioning anchor.
- `collisionAvoidance`: `CollisionAvoidance`. 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', }} /> `
- `className`: `string | ((state: PopoverPositionerState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Popover.Title

- `className`: `string | ((state: PopoverTitleState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

### Popover.Trigger

- `nativeButton`: `boolean`. Defaults to `true`. 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 (for example, `<div>`).
- `className`: `string | ((state: PopoverTriggerState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.

Docs updated 19 Oct 2026.
