# Popover (/ui/components/react/popover)



<Playground component="popover" />

## Usage [#usage]

```tsx
import {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverTitle,
  PopoverDescription,
  PopoverClose,
} from '@appica/ui-react/popover'
import { Button } from '@appica/ui-react/button'
```

```tsx
<Popover>
  <PopoverTrigger render={<Button variant="outline">Open</Button>} />
  <PopoverContent>
    <PopoverTitle>Share this page</PopoverTitle>
    <PopoverDescription>Anyone with the link can view this document.</PopoverDescription>
  </PopoverContent>
</Popover>
```

Unlike a plain `<button>`, the trigger is wired to the popup through `render` — pass it your own [`Button`](/ui/components/react/button) (or any element) and the popover manages its `aria-expanded`, focus, and open/close. Every part lives in its own import under `@appica/ui-react/popover`.

`Popover` is a compound component built on [Base UI's Popover](https://base-ui.com/react/components/popover). The root holds the open state; the parts compose the trigger and the portalled popup:

* **`PopoverTrigger`** — the button that toggles the popover. Use `render` to project it onto your own [`Button`](/ui/components/react/button) (or any element). Set `openOnHover` to open it on hover.
* **`PopoverContent`** — the portalled, auto-positioned popup. It draws the card, border, shadow, and the optional `arrow`, and accepts the shared **floating props** below.
* **`PopoverTitle`** — an `<h2>` that labels the popup (`aria-labelledby`).
* **`PopoverDescription`** — a `<p>` that describes it (`aria-describedby`).
* **`PopoverClose`** — a button that dismisses the popover; project it onto your own [`Button`](/ui/components/react/button) with `render`.

`PopoverContent` is the shared **floating surface** used across the library — `DropdownMenu`, `Tooltip`, `Select`, `Combobox`, and others reuse the same positioning props. They flatten the underlying Base UI Positioner so you can place the popup without reaching into the portal:

* `side` (`top` / `right` / `bottom` / `left`) and `align` (`start` / `center` / `end`) set the preferred placement; it still **flips and shifts** automatically to stay in view.
* `sideOffset` / `alignOffset` nudge it off the anchor; `collisionPadding` keeps a gap from the viewport edge.
* `positionerProps` / `portalProps` are escape hatches for the inner Positioner and Portal (`style`, `render`, `dir`, …); `container` re-targets the portal.

Reach for a `Popover` when you need **rich, interactive content** floating beside a control — a form, a set of settings, a share sheet. For a list of **actions**, use a [`Dropdown Menu`](/ui/components/react/dropdown-menu); for a plain text **hint**, use a [`Tooltip`](/ui/components/react/tooltip); for a hover **preview of a link**, use a [`Preview Card`](/ui/components/react/preview-card).

## Examples [#examples]

### Form in a popover [#form-in-a-popover]

Popovers can hold interactive content, including focusable form controls. Lay out [`Field`](/ui/components/react/field)s inside the popup and dismiss it with `PopoverClose` projected onto your action buttons — clicking either one closes the popover. Widen the popup past its default with a `className` (`w-72` here).

```tsx
import {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverTitle,
  PopoverDescription,
  PopoverClose,
} from '@appica/ui-react/popover'
import { Button } from '@appica/ui-react/button'
import { Field, FieldLabel } from '@appica/ui-react/field'
import { Input } from '@appica/ui-react/input'
import { Adjustments } from '@appica/icons-react'

export default function PopoverForm() {
  return (
    <Popover>
      <PopoverTrigger
        render={
          <Button variant="outline">
            <Adjustments data-icon="start" />
            Dimensions
          </Button>
        }
      />
      <PopoverContent className="w-72">
        <PopoverTitle>Dimensions</PopoverTitle>
        <PopoverDescription>Set the size of the selected layer.</PopoverDescription>
        <div className="mt-1 grid grid-cols-2 gap-3">
          <Field>
            <FieldLabel>Width</FieldLabel>
            <Input defaultValue="320" inputMode="numeric" />
          </Field>
          <Field>
            <FieldLabel>Height</FieldLabel>
            <Input defaultValue="180" inputMode="numeric" />
          </Field>
        </div>
        <div className="mt-2 flex justify-end gap-2">
          <PopoverClose
            render={
              <Button variant="ghost" size="sm">
                Cancel
              </Button>
            }
          />
          <PopoverClose render={<Button size="sm">Apply</Button>} />
        </div>
      </PopoverContent>
    </Popover>
  )
}
```

### Controlled [#controlled]

Drive the open state yourself with `open` + `onOpenChange` (or stay uncontrolled with `defaultOpen`). Here a status picker closes the popup the moment you choose an option — `setOpen(false)` straight from the row's handler — while the trigger reflects the current selection. Owning the state is what lets you close on select, open the popover from elsewhere, or keep it in sync with the rest of your UI.

```tsx
'use client'

import * as React from 'react'
import { Popover, PopoverTrigger, PopoverContent, PopoverTitle } from '@appica/ui-react/popover'
import { NavigationLink } from '@appica/ui-react/navigation'
import { Button } from '@appica/ui-react/button'
import { Check } from '@appica/icons-react'

const PRESETS = [
  { emoji: '🟢', label: 'Active' },
  { emoji: '🗓️', label: 'In a meeting' },
  { emoji: '🎧', label: 'Focusing' },
  { emoji: '🌴', label: 'On vacation' },
  { emoji: '🤒', label: 'Out sick' },
] as const

type Status = (typeof PRESETS)[number]

export default function PopoverControlled() {
  const [open, setOpen] = React.useState(false)
  const [status, setStatus] = React.useState<Status>(PRESETS[0])

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger
        render={
          <Button variant="outline">
            <span aria-hidden>{status.emoji}</span>
            {status.label}
          </Button>
        }
      />
      <PopoverContent align="start" className="w-60 gap-0.5 p-1.5">
        <PopoverTitle className="text-foreground-muted px-2 pt-1 pb-1.5 text-xs font-medium">Set a status</PopoverTitle>
        {PRESETS.map((preset) => (
          <NavigationLink
            key={preset.label}
            orientation="vertical"
            active={preset.label === status.label}
            className="w-full"
            render={
              <button
                type="button"
                onClick={() => {
                  setStatus(preset)
                  setOpen(false)
                }}
              />
            }
          >
            <span aria-hidden>{preset.emoji}</span>
            {preset.label}
            {preset.label === status.label && <Check data-icon="end" className="text-primary ms-auto" />}
          </NavigationLink>
        ))}
      </PopoverContent>
    </Popover>
  )
}
```

### Open on hover [#open-on-hover]

Set `openOnHover` on the `PopoverTrigger` to reveal the popup on pointer rest (it still opens on click and keyboard). Tune the timing with `delay` / `closeDelay`. Use it for supplementary detail like a richer-than-tooltip hint — but remember hover isn't reachable on touch, so don't hide essential actions behind it.

```tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverTitle, PopoverDescription } from '@appica/ui-react/popover'
import { Button } from '@appica/ui-react/button'
import { InfoCircle } from '@appica/icons-react'

export default function PopoverHover() {
  return (
    <Popover>
      <PopoverTrigger
        openOnHover
        delay={150}
        render={
          <Button variant="ghost" size="icon-md" aria-label="Plan details">
            <InfoCircle />
          </Button>
        }
      />
      <PopoverContent side="top">
        <PopoverTitle>Pro plan</PopoverTitle>
        <PopoverDescription>
          Unlimited projects, priority support, and 100 GB of storage. Renews monthly.
        </PopoverDescription>
      </PopoverContent>
    </Popover>
  )
}
```

### Without an arrow [#without-an-arrow]

Pass `arrow={false}` to drop the pointer. The popup also pulls a little closer to the trigger and loses the thicker anchored border, for a flatter, menu-like surface.

```tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverTitle, PopoverDescription } from '@appica/ui-react/popover'
import { Button } from '@appica/ui-react/button'

export default function PopoverNoArrow() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline">No arrow</Button>} />
      <PopoverContent arrow={false}>
        <PopoverTitle>Cleaner edge</PopoverTitle>
        <PopoverDescription>
          Dropping the arrow pulls the popup closer to the trigger and removes the pointer and the thicker anchored
          border.
        </PopoverDescription>
      </PopoverContent>
    </Popover>
  )
}
```

### Side & alignment [#side--alignment]

Position the popup with `side` (`top` / `right` / `bottom` / `left`) and `align` (`start` / `center` / `end`) on `PopoverContent`. Wherever you point it, it still flips and shifts automatically to stay within the viewport.

```tsx
import { Popover, PopoverTrigger, PopoverContent, PopoverTitle, PopoverDescription } from '@appica/ui-react/popover'
import { Button } from '@appica/ui-react/button'

const PLACEMENTS = [
  { side: 'top', align: 'start' },
  { side: 'right', align: 'center' },
  { side: 'bottom', align: 'end' },
  { side: 'left', align: 'center' },
] as const

export default function PopoverPlacement() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      {PLACEMENTS.map(({ side, align }) => (
        <Popover key={`${side}-${align}`}>
          <PopoverTrigger
            render={
              <Button variant="outline">
                {side} / {align}
              </Button>
            }
          />
          <PopoverContent side={side} align={align}>
            <PopoverTitle>
              {side} / {align}
            </PopoverTitle>
            <PopoverDescription>The popup flips and shifts automatically to stay in view.</PopoverDescription>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  )
}
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your tree in `DirectionProvider` so direction-aware behavior — popup placement and alignment — follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

The popup anchors from the trigger's leading edge and `start`/`end` alignment mirrors. For setup details and caveats, see the [RTL guide](/ui/docs/react/rtl).

<Callout type="note" title="Scoped RTL needs the direction on the positioner">
  The popup portals to `<body>`, so its `start`/`end` alignment mirrors from the **document** direction (read from CSS,
  not from `DirectionProvider`) — automatic when `<html dir="rtl">`. If you instead scope RTL to a subtree of an LTR
  page, set it on the positioner so alignment still mirrors: `<PopoverContent positionerProps={{ dir: 'rtl' }}>`. See
  [Floating popups in a scoped RTL region](/ui/docs/react/rtl#floating-popups-in-a-scoped-rtl-region).
</Callout>

<RtlPreview component="popover" />

## API reference [#api-reference]

`Popover` wraps [Base UI's Popover](https://base-ui.com/react/components/popover). Each part forwards every remaining prop to the matching Base UI primitive, so anything it accepts works here — the tables below list the Appica additions and the most common props. See the Base UI docs for the complete API.

### Popover [#popover]

The root. Holds the open state; renders no DOM of its own.

| Prop                   | <ColMinWidth width="200">Type</ColMinWidth> | Default | <ColMinWidth width="220">Description</ColMinWidth>          |
| ---------------------- | ------------------------------------------- | ------- | ----------------------------------------------------------- |
| `open`                 | `boolean`                                   | —       | Controlled open state. Pair with `onOpenChange`.            |
| `defaultOpen`          | `boolean`                                   | `false` | Uncontrolled initial open state.                            |
| `onOpenChange`         | <Code>(open, eventDetails) => void</Code>   | —       | Fires when the popover opens or closes.                     |
| `modal`                | <Code>boolean \| 'trap-focus'</Code>        | `false` | Trap focus and block outside scroll/interaction while open. |
| `onOpenChangeComplete` | `(open) => void`                            | —       | Fires after the open/close transition finishes.             |
| `actionsRef`           | `RefObject`                                 | —       | Imperative handle to `close()` / `unmount()` the popup.     |

### PopoverTrigger [#popovertrigger]

The button that toggles the popover. Forwards Base UI's `Popover.Trigger`; use `render` to project it onto your own element.

| Prop           | <ColMinWidth width="220">Type</ColMinWidth>                 | Default | Description                                                     |
| -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------- |
| `render`       | <Code>ReactElement \| (props, state) => ReactElement</Code> | —       | Render the trigger as your own element (e.g. a `Button`).       |
| `openOnHover`  | `boolean`                                                   | `false` | Open the popover when the trigger is hovered, not just clicked. |
| `delay`        | `number`                                                    | `300`   | Hover-open delay in ms (with `openOnHover`).                    |
| `closeDelay`   | `number`                                                    | `0`     | Hover-close delay in ms (with `openOnHover`).                   |
| `nativeButton` | `boolean`                                                   | `true`  | Whether the rendered element is a native `<button>`.            |
| `className`    | `string`                                                    | —       | Extra classes on the trigger, merged via `tailwind-merge`.      |

### PopoverContent [#popovercontent]

The portalled, auto-positioned popup. Draws the card and the optional arrow, and accepts the shared floating props. Defaults to `side="bottom"`, `align="center"`, and `sideOffset={10}` (or `6` without an arrow).

| Prop                | <ColMinWidth width="160">Type</ColMinWidth>         | Default    | Description                                                            |
| ------------------- | --------------------------------------------------- | ---------- | ---------------------------------------------------------------------- |
| `arrow`             | `boolean`                                           | `true`     | Render the pointer and the thicker anchored border.                    |
| `side`              | <Code>'top' \| 'right' \| 'bottom' \| 'left'</Code> | `'bottom'` | Preferred side to open on.                                             |
| `align`             | <Code>'start' \| 'center' \| 'end'</Code>           | `'center'` | Alignment along that side.                                             |
| `sideOffset`        | `number`                                            | `10`       | Gap between the trigger and the popup.                                 |
| `alignOffset`       | `number`                                            | `0`        | Shift along the alignment axis.                                        |
| `collisionPadding`  | `number`                                            | `5`        | Minimum gap kept from the viewport edge when flipping/shifting.        |
| `collisionBoundary` | `Boundary`                                          | —          | Element(s) the popup must stay within.                                 |
| `sticky`            | `boolean`                                           | `false`    | Keep the popup in view while the anchor scrolls off.                   |
| `anchor`            | <Code>Element \| ref \| VirtualElement</Code>       | —          | Anchor against a different element than the trigger.                   |
| `container`         | <Code>HTMLElement \| ref \| null</Code>             | —          | Portal target — render the popup into a different node.                |
| `keepMounted`       | `boolean`                                           | `false`    | Keep the popup mounted in the DOM while closed.                        |
| `positionerProps`   | `object`                                            | —          | Escape hatch for the positioner element (`style`, `render`, `dir`, …). |
| `portalProps`       | `object`                                            | —          | Escape hatch for the portal element (`style`, `render`, …).            |
| `className`         | `string`                                            | —          | Extra classes on the popup card.                                       |

### PopoverTitle [#popovertitle]

An `<h2>` that labels the popup. Rendering one sets the popup's `aria-labelledby`.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | The title text.                             |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### PopoverDescription [#popoverdescription]

A `<p>` that describes the popup. Rendering one sets the popup's `aria-describedby`.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | The description text.                       |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### PopoverClose [#popoverclose]

A button that closes the popover. Forwards Base UI's `Popover.Close`; project it onto your own element with `render`.

| Prop           | <ColMinWidth width="220">Type</ColMinWidth>                 | Default | Description                                          |
| -------------- | ----------------------------------------------------------- | ------- | ---------------------------------------------------- |
| `render`       | <Code>ReactElement \| (props, state) => ReactElement</Code> | —       | Render the close control as your own element.        |
| `nativeButton` | `boolean`                                                   | `true`  | Whether the rendered element is a native `<button>`. |
| `className`    | `string`                                                    | —       | Extra classes, merged via `tailwind-merge`.          |

## Accessibility [#accessibility]

* The trigger is a `button` with `aria-haspopup="dialog"` and `aria-expanded`; the popup has `role="dialog"`.
* `PopoverTitle` and `PopoverDescription` wire the popup's `aria-labelledby` / `aria-describedby` automatically — prefer them over hand-rolled headings.
* **Enter / Space** open the popover from the trigger, **Escape** closes it, and focus returns to the trigger on close.
* With `modal`, focus is trapped inside the open popup and outside scroll/interaction is blocked; the default (`false`) leaves the rest of the page interactive.
* Popup enter/exit transitions are skipped when the user prefers reduced motion.
