---
name: popover
type: core
domain: overlays
requires: [loke-ui]
description: >
  Floating panels anchored to a trigger or custom element. Popover, PopoverTrigger,
  PopoverAnchor, PopoverPortal, PopoverContent, PopoverClose, PopoverArrow.
  Non-modal by default (modal=false). Popper positioning via Floating UI.
  CSS custom properties: --loke-popover-content-available-height, -width, -transform-origin,
  --loke-popover-trigger-height, -width.
---

# Popover

## Setup

Basic popover with portal, arrow, and close button.

```tsx
import {
  Popover,
  PopoverArrow,
  PopoverClose,
  PopoverContent,
  PopoverPortal,
  PopoverTrigger,
} from "@loke/ui/popover";

function Example() {
  return (
    <Popover>
      <PopoverTrigger>Open popover</PopoverTrigger>
      <PopoverPortal>
        <PopoverContent>
          <p>Popover content goes here.</p>
          <PopoverClose>Close</PopoverClose>
          <PopoverArrow />
        </PopoverContent>
      </PopoverPortal>
    </Popover>
  );
}
```

`PopoverContent` renders with `role="dialog"` and positions via Floating UI (Popper). `PopoverArrow` points toward the trigger. `PopoverPortal` renders into `document.body`.

`Popover` defaults to `modal={false}` — no focus trapping and no pointer event blocking on the background.

## Core Patterns

### Controlled open state

```tsx
import { useState } from "react";
import {
  Popover,
  PopoverContent,
  PopoverPortal,
  PopoverTrigger,
} from "@loke/ui/popover";

function ControlledPopover() {
  const [open, setOpen] = useState(false);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger>Edit</PopoverTrigger>
      <PopoverPortal>
        <PopoverContent>
          <input placeholder="Name" />
          <button type="button" onClick={() => setOpen(false)}>
            Save
          </button>
        </PopoverContent>
      </PopoverPortal>
    </Popover>
  );
}
```

### Custom anchor element

Use `PopoverAnchor` to position the popover against an element other than the trigger. Once `PopoverAnchor` is rendered, `PopoverTrigger` no longer acts as the positioning anchor.

```tsx
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverPortal,
  PopoverTrigger,
} from "@loke/ui/popover";

function AnchoredPopover() {
  return (
    <Popover>
      <div className="input-row">
        <PopoverAnchor asChild>
          <input placeholder="Search..." />
        </PopoverAnchor>
        <PopoverTrigger>Filter</PopoverTrigger>
      </div>
      <PopoverPortal>
        {/* Content aligns to the input, not the button */}
        <PopoverContent align="start">
          <p>Filter options</p>
        </PopoverContent>
      </PopoverPortal>
    </Popover>
  );
}
```

### Modal popover with focus trapping

```tsx
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverPortal,
  PopoverTrigger,
} from "@loke/ui/popover";

function ModalPopover() {
  return (
    <Popover modal>
      <PopoverTrigger>Edit profile</PopoverTrigger>
      <PopoverPortal>
        <PopoverContent>
          <input placeholder="Display name" />
          <input placeholder="Email" />
          <PopoverClose>Save</PopoverClose>
        </PopoverContent>
      </PopoverPortal>
    </Popover>
  );
}
```

`modal={true}` enables focus trapping (FocusScope), disables pointer events on the background (DismissableLayer), and hides background from screen readers (`aria-hidden`). Use for forms where accidental background interaction would be disruptive.

### CSS custom properties

`PopoverContent` re-maps Popper internal vars to `--loke-popover-*` names. Use these to size content relative to available space or match trigger dimensions.

```css
.popover-content {
  /* Constrain to available height in viewport */
  max-height: var(--loke-popover-content-available-height);
  /* Match trigger width */
  min-width: var(--loke-popover-trigger-width);
  /* Correct transform-origin for scale animations */
  transform-origin: var(--loke-popover-content-transform-origin);
}
```

Available properties set on `PopoverContent`:

| Property | Value source |
|---|---|
| `--loke-popover-content-available-height` | `--loke-popper-available-height` |
| `--loke-popover-content-available-width` | `--loke-popper-available-width` |
| `--loke-popover-content-transform-origin` | `--loke-popper-transform-origin` |
| `--loke-popover-trigger-height` | `--loke-popper-anchor-height` |
| `--loke-popover-trigger-width` | `--loke-popper-anchor-width` |

## Common Mistakes

### PopoverAnchor and PopoverTrigger anchor confusion

When `PopoverAnchor` is mounted, `hasCustomAnchor` becomes true and `PopoverTrigger` stops wrapping itself in a `PopperPrimitive.Anchor`. The popover positions against `PopoverAnchor`, not the trigger. Mixing both without understanding this causes the popover to appear in the wrong location.

```tsx
// Wrong — expects popover to anchor to the trigger button
<Popover>
  <PopoverAnchor asChild><div className="target" /></PopoverAnchor>
  <PopoverTrigger>Open</PopoverTrigger>
  {/* Popover positions against .target, not "Open" */}
  <PopoverPortal>
    <PopoverContent>...</PopoverContent>
  </PopoverPortal>
</Popover>
```

Source: `src/components/popover/popover.tsx` — `hasCustomAnchor` logic in `PopoverTrigger`.

### Expecting non-modal popover to trap focus

`Popover` defaults to `modal={false}`. In this mode there is no `FocusScope` trapping and no `disableOutsidePointerEvents`. Clicks and keyboard focus can leave the popover freely. Set `modal={true}` if you need a contained form experience.

```tsx
// Wrong — user expects tab to stay inside
<Popover>
  <PopoverTrigger>Edit</PopoverTrigger>
  <PopoverPortal>
    <PopoverContent>
      <input />
    </PopoverContent>
  </PopoverPortal>
</Popover>

// Correct for focus-trapped forms
<Popover modal>
  <PopoverTrigger>Edit</PopoverTrigger>
  <PopoverPortal>
    <PopoverContent>
      <input />
    </PopoverContent>
  </PopoverPortal>
</Popover>
```

Source: `src/components/popover/popover.tsx` — `modal` defaults to `false`.

### Using wrong CSS custom property names

The internal Popper vars (`--loke-popper-*`) are not exposed on `PopoverContent`. They are re-mapped to `--loke-popover-*`. Referencing the raw popper names in CSS will silently resolve to nothing.

```css
/* Wrong */
max-height: var(--loke-popper-available-height);

/* Correct */
max-height: var(--loke-popover-content-available-height);
```

Source: `src/components/popover/popover.tsx` — CSS var re-mapping in `PopoverContentImpl`.

### Skipping PopoverPortal

Without `PopoverPortal`, `PopoverContent` renders inline in the DOM tree. This causes z-index stacking failures and `overflow: hidden` clipping when the trigger is inside a scrollable container.

## Cross-references

- See also: [tooltip](../tooltip/SKILL.md) — for non-interactive hover labels
- See also: [dialog](../dialog/SKILL.md) — for blocking modal overlays
- See also: [dropdown-menu](../dropdown-menu/SKILL.md) — for selection menus triggered by a button
- See also: [overlay-infrastructure](../overlay-infrastructure/SKILL.md) — Popper, DismissableLayer, FocusScope internals
