# Popover

> Show supporting information or controls in a non-modal popup.

Source: https://www.honestui.com/docs/components/popover

```tsx
import { Button } from "@/components/honest-ui/ui/button"
import { Field } from "@/components/honest-ui/ui/field"
import { Form } from "@/components/honest-ui/ui/form"
import {
  Popover,
  PopoverDescription,
  PopoverPopup,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/honest-ui/ui/popover"
import { Textarea } from "@/components/honest-ui/ui/textarea"

export function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="secondary" />}>
        Open Popover
      </PopoverTrigger>
      <PopoverPopup className="w-80">
        <div className="mb-4">
          <PopoverTitle>Send us feedback</PopoverTitle>
          <PopoverDescription>
            Let us know how we can improve.
          </PopoverDescription>
        </div>
        <Form className="grid gap-4">
          <Field>
            <Textarea
              id="feedback"
              placeholder="How can we improve?"
              aria-label="Send feedback"
            />
          </Field>
          <Button type="submit">Send feedback</Button>
        </Form>
      </PopoverPopup>
    </Popover>
  )
}

```

## Overview [#overview]

Use a Popover for contextual content that appears near a trigger when the person asks for it: lightweight forms, explanations, filters, small pickers, and secondary controls that would crowd the page if they were always visible.

A popover is deliberately opened, so it can hold interactive controls — unlike a Tooltip, which disappears as soon as focus moves and should never contain anything a person needs to operate. When the task needs the rest of the page blocked or a larger workflow, use Dialog instead; a popover is non-modal by default and lets people keep working while it is open.

## Anatomy [#anatomy]

A popover has a trigger, popup, optional title, optional description, content, and an optional close action. `PopoverPopup` renders the portal and positioner internally, so positioning props like `side` and `align` sit directly on it. The title names what opened; the description carries supporting context. Both wire into the popup's accessible name automatically when present.

## Behavior [#behavior]

**Opening.** Clicking the trigger opens the popup and moves focus into it — to the first tabbable control inside, or to the popup itself when there is nothing tabbable. If it was opened by touch, the popup element receives focus so the on-screen keyboard does not pop open over content nobody can see yet.

**While open.** The default mode is non-modal: <kbd>Tab</kbd> walks through the popup's controls and then continues back into the page. Pressing <kbd>Escape</kbd> closes it, and clicking outside closes it too. Set `modal` on the root to `"trap-focus"` (focus cycles inside but the page stays scrollable) or `true` (interaction limited to the popup and page scroll locked) when the content must be dealt with before continuing.

**Closing.** Focus returns to the trigger, so keyboard users continue where they left off. Pass `finalFocus` on the popup to send focus somewhere else.

**Positioning.** `side` places the popup relative to the trigger (`bottom` by default), `align` sets alignment within that side, and `sideOffset` adds distance. When there is no room, the popup flips to the opposite side or shifts sideways to stay in the viewport, and its height never exceeds the available viewport space. The positioner forwards Base UI's full set of collision options if you need finer control.

## Accessibility [#accessibility]

The popup gets `role="dialog"`. Its accessible name comes from `PopoverTitle` and its description from `PopoverDescription`, so include both whenever the content needs context — an unlabeled dialog is announced as just "dialog". The trigger keeps its own name and state (`aria-expanded`), which is why the trigger text should describe what opens.

Everything dismissible by mouse is dismissible by keyboard: <kbd>Escape</kbd> always works, and moving focus outside the popup closes it. Because focus returns to the trigger on close, a popover is safe to open from the keyboard mid-task.

Colors come from theme tokens, so the popup adapts to dark mode automatically. Padding uses logical properties, so the layout mirrors in right-to-left locales. Content wraps inside the popup's capped width rather than overflowing; very long text grows the popup vertically up to the viewport limit before scrolling becomes your responsibility to style.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;popover&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/popover.tsx

```tsx
"use client"

import { Popover as PopoverPrimitive } from "@base-ui-components/react/popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

function PopoverTrigger(props: PopoverPrimitive.Trigger.Props) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

function PopoverPopup({
  children,
  className,
  side = "bottom",
  align = "center",
  sideOffset = 4,
  ...props
}: PopoverPrimitive.Popup.Props & {
  side?: PopoverPrimitive.Positioner.Props["side"]
  align?: PopoverPrimitive.Positioner.Props["align"]
  sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]
}) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        data-slot="popover-positioner"
        className="z-[var(--hui-z-index-portal)]"
        side={side}
        sideOffset={sideOffset}
        align={align}
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          className={cn(
            "box-border max-h-(--available-height) min-w-[var(--hui-space-17)] max-w-[18rem] origin-(--transform-origin) overflow-hidden rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] p-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-soft)] outline-0 [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] [transition:opacity_var(--hui-duration-normal)_var(--hui-ease-out)] data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:[transition:opacity_var(--hui-duration-normal)_var(--hui-ease-out),transform_var(--hui-duration-normal)_var(--hui-ease-out)] motion-safe:data-ending-style:scale-[0.97] motion-safe:data-starting-style:scale-[0.97]",
            className
          )}
          {...props}
        >
          {children}
        </PopoverPrimitive.Popup>
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
  return <PopoverPrimitive.Close data-slot="popover-close" {...props} />
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return (
    <PopoverPrimitive.Title
      data-slot="popover-title"
      className={cn(
        "m-0 text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-large)] [font-style:normal] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-large)] [line-height:var(--hui-line-height-large)]",
        className
      )}
      {...props}
    />
  )
}

function PopoverDescription({
  className,
  ...props
}: PopoverPrimitive.Description.Props) {
  return (
    <PopoverPrimitive.Description
      data-slot="popover-description"
      className={cn(
        "m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [font-style:normal] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]",
        className
      )}
      {...props}
    />
  )
}
export {
  Popover,
  PopoverTrigger,
  PopoverPopup,
  PopoverPopup as PopoverContent,
  PopoverTitle,
  PopoverDescription,
  PopoverClose,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  Popover,
  PopoverClose,
  PopoverDescription,
  PopoverPopup,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover";
```

```tsx
<Popover>
  <PopoverTrigger>View keyboard shortcuts</PopoverTrigger>
  <PopoverPopup>
    <PopoverTitle>Keyboard shortcuts</PopoverTitle>
    <PopoverDescription>Press Command and K to open search.</PopoverDescription>
    <PopoverClose>Close</PopoverClose>
  </PopoverPopup>
</Popover>
```

`PopoverContent` is an alias of `PopoverPopup`. Use controlled state (`open` / `onOpenChange` on the root) only when something else must own whether the popover is visible.

## Don't do this [#dont-do-this]

### Confirming destructive actions in a popover [#confirming-destructive-actions-in-a-popover]

```tsx
// Bad
<PopoverPopup>
  <PopoverTitle>Delete project?</PopoverTitle>
  <Button variant="destructive" onClick={destroy}>Delete</Button>
</PopoverPopup>
```

```tsx
// Good
<AlertDialog>
  <AlertDialogTrigger render={<Button variant="destructive" />}>
    Delete project
  </AlertDialogTrigger>
  <AlertDialogPopup initialFocus={cancelRef}>
    <AlertDialogTitle>Delete Aurora website?</AlertDialogTitle>
    <AlertDialogDescription>
      This permanently removes the site and all deployments.
    </AlertDialogDescription>
    <AlertDialogFooter>
      <AlertDialogClose render={<Button variant="ghost" ref={cancelRef} />}>
        Cancel
      </AlertDialogClose>
      <Button variant="destructive">Delete</Button>
    </AlertDialogFooter>
  </AlertDialogPopup>
</AlertDialog>
```

A popover closes on any outside click, which means a stray click can silently cancel a confirmation — or worse, leave someone unsure whether the action ran. Destructive confirmations need a modal surface that interrupts, states the consequence, and cannot be dismissed by accident. That is AlertDialog's job.

### Omitting the title [#omitting-the-title]

```tsx
// Bad
<PopoverPopup className="w-72">
  <Slider defaultValue={40} aria-label="Opacity" />
</PopoverPopup>
```

```tsx
// Good
<PopoverPopup className="w-72">
  <PopoverTitle>Opacity</PopoverTitle>
  <Slider defaultValue={40} aria-label="Opacity" />
</PopoverPopup>
```

The popup is announced as a dialog, and its name comes from the title. Without one, screen-reader users hear "dialog" with no indication of what it controls. A visually hidden title still provides the name if the design has none.

### Opening on hover to fake a tooltip [#opening-on-hover-to-fake-a-tooltip]

```tsx
// Bad
<PopoverTrigger openOnHover delay={0}>
  Details
</PopoverTrigger>
```

```tsx
// Good
<Tooltip>
  <TooltipTrigger>Details</TooltipTrigger>
  <TooltipPopup>Shows archived projects too.</TooltipPopup>
</Tooltip>
```

A hover-opened popup appears under the cursor without being asked for, swallows clicks meant for nearby content, and lingers unpredictably. Text hints belong in Tooltips, which are tuned for hover and focus with delays and instant grouping; Popovers are for content people choose to open.

## Examples [#examples]

### With Close Button [#with-close-button]

An explicit close action plus a corner dismiss button. Escape and outside clicks work regardless.

```tsx
import { X as XIcon } from "honestui/icons"

import { Button } from "@/components/honest-ui/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverDescription,
  PopoverPopup,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/honest-ui/ui/popover"

export function PopoverWithCloseDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="secondary" />}>
        Open Popover
      </PopoverTrigger>
      <PopoverPopup className="w-80">
        <PopoverClose className="absolute end-2 top-2 inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-72 transition-[color,background-color,box-shadow,opacity] outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background pointer-coarse:after:absolute pointer-coarse:after:-inset-1 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
          <XIcon />
          <span className="sr-only">Close</span>
        </PopoverClose>
        <div className="mb-2">
          <PopoverTitle className="text-base">
            Notifications
          </PopoverTitle>
          <PopoverDescription>
            You are all caught up. Good job!
          </PopoverDescription>
        </div>
        <PopoverClose render={<Button variant="secondary" />}>
          Close
        </PopoverClose>
      </PopoverPopup>
    </Popover>
  )
}

```

### Account summary [#account-summary]

Read-only detail revealed on demand, with title and description providing the accessible name.

```tsx
import { Button } from "@/components/honest-ui/ui/button"
import { Popover, PopoverDescription, PopoverPopup, PopoverTitle, PopoverTrigger } from "@/components/honest-ui/ui/popover"

export function PopoverAccountSummary() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="secondary" />}>Account</PopoverTrigger>
      <PopoverPopup className="w-72">
        <PopoverTitle>Acme workspace</PopoverTitle>
        <PopoverDescription>12 members, 4 active projects, and 84% storage used.</PopoverDescription>
      </PopoverPopup>
    </Popover>
  )
}

```

### Small picker [#small-picker]

Compact choice sets fit a popover well. Larger pickers deserve a Dialog or their own route.

```tsx
import { Button } from "@/components/honest-ui/ui/button"
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "@/components/honest-ui/ui/popover"

const colors = ["bg-blue-500", "bg-emerald-500", "bg-amber-500", "bg-rose-500"]

export function PopoverColorSwatch() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="secondary" />}>Pick color</PopoverTrigger>
      <PopoverPopup className="w-56">
        <PopoverTitle className="mb-3 text-base">Label color</PopoverTitle>
        <div className="flex gap-2">
          {colors.map((color) => <button key={color} className={
            "size-8 rounded-full border " + color
          } aria-label={color} />)}
        </div>
      </PopoverPopup>
    </Popover>
  )
}

```

## API reference [#api-reference]

All parts forward their matching Base UI Popover props. Honest UI adds positioning shortcuts on `PopoverPopup`:

| Prop         | Values                                                          | Default  |
| ------------ | --------------------------------------------------------------- | -------- |
| `side`       | `top`, `right`, `bottom`, `left`, and logical `inline` variants | `bottom` |
| `align`      | `start`, `center`, `end`                                        | `center` |
| `sideOffset` | number                                                          | `4`      |

The root accepts `open` / `onOpenChange` for controlled usage and `modal={false | 'trap-focus' | true}` (`false` by default). The popup accepts `initialFocus` and `finalFocus` to direct where focus lands on open and close. `PopoverContent` aliases `PopoverPopup`.

See the [Base UI Popover API](https://base-ui.com/react/components/popover#api-reference).
