# Popover

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/popover/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

A surface that hangs from a trigger and closes on Escape, an outside click or scrolling (also called a popup, flyout or dropdown panel). Reach for it for a small panel of your own: a filter, a detail, a short form.

```ts
import { Popover } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/popover/

It is controlled: you hold `open`, and `onOpenChange` reports each wish to close. Only you know whether it may close now, for instance with a half-filled form in it.

It places itself below its anchor, flips above where there is no room and stays inside the window. Inside a dialog it portals into that dialog, so it never lands behind it.

`Menu`, `Tooltip`, `Select`, `Combobox`, `MultiSelect` and the pickers stand on it, so they all place and dismiss the same way.

## Examples

### An anchored surface

You hold `open`, the popover hangs from `anchorRef` and reports every wish to close through `onOpenChange`.

```tsx
import { useRef, useState } from "react";
import { Button, Popover, Stack, Text } from "@umriss-ui/core";

export default function AnchoredSurface() {
  const anchor = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button ref={anchor} onClick={() => setOpen((o) => !o)} aria-expanded={open}>
        Sprint 14
      </Button>
      <Popover open={open} onOpenChange={setOpen} anchorRef={anchor} role="dialog" ariaLabel="Sprint 14" restoreFocus width={280}>
        <Stack gap={2} style={{ padding: "var(--u-space-4)" }}>
          <Text size="sm" weight="medium">
            Member portal sign-in and profile
          </Text>
          <Text size="sm" tone="secondary">
            9 to 20 March · 16 items · 334 hours planned
          </Text>
        </Stack>
      </Popover>
    </>
  );
}
```

### Take the width of the trigger

`width` set to `anchor` makes the surface as wide as its trigger, as a list under a field needs; `minWidth` keeps it readable.

```tsx
import { useRef, useState } from "react";
import { Button, Popover, Stack, Text } from "@umriss-ui/core";

export default function WidthFromAnchor() {
  const anchor = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button ref={anchor} style={{ width: 240 }} onClick={() => setOpen((o) => !o)} aria-expanded={open}>
        Riverside depot · 2 vehicles
      </Button>
      <Popover
        open={open}
        onOpenChange={setOpen}
        anchorRef={anchor}
        role="dialog"
        ariaLabel="Vehicles at Riverside depot"
        width="anchor"
        minWidth={180}
        restoreFocus
      >
        <Stack gap={1} style={{ padding: "var(--u-space-3)" }}>
          <Text size="sm" mono>FP 402 R · van</Text>
          <Text size="sm" mono>FP 455 R · e-van</Text>
        </Stack>
      </Popover>
    </>
  );
}
```

### Filter a list from a panel

A small form in a popover: it applies on the button, gives focus back to its trigger and leaves the list in view.

```tsx
import { useRef, useState } from "react";
import { Button, Checkbox, Popover, Stack, Text } from "@umriss-ui/core";

const DEPOTS = ["North depot", "Riverside depot", "East Gate depot"];

const SHIPMENTS = [
  { id: "SH-1042", customer: "Harlow Bakery", depot: "North depot" },
  { id: "SH-1051", customer: "Pine & Oak Florist", depot: "Riverside depot" },
  { id: "SH-1063", customer: "Moreau Optics", depot: "East Gate depot" },
  { id: "SH-1064", customer: "Quayside Books", depot: "East Gate depot" },
];

export default function AFilterPanel() {
  const anchor = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);
  const [applied, setApplied] = useState<string[]>(DEPOTS);
  const [draft, setDraft] = useState<string[]>(DEPOTS);

  const toggle = (depot: string) =>
    setDraft((current) => (current.includes(depot) ? current.filter((d) => d !== depot) : [...current, depot]));

  return (
    <Stack gap={3} align="flex-start">
      <Button
        ref={anchor}
        aria-expanded={open}
        onClick={() => {
          setDraft(applied);
          setOpen((o) => !o);
        }}
      >
        Depots: {applied.length === DEPOTS.length ? "all" : applied.length}
      </Button>
      <Popover open={open} onOpenChange={setOpen} anchorRef={anchor} role="dialog" ariaLabel="Filter by depot" restoreFocus width={260}>
        <Stack gap={3} style={{ padding: "var(--u-space-4)" }}>
          {DEPOTS.map((depot) => (
            <Checkbox key={depot} label={depot} checked={draft.includes(depot)} onChange={() => toggle(depot)} />
          ))}
          <Stack direction="row" gap={2} justify="flex-end">
            <Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button
              size="sm"
              variant="primary"
              onClick={() => {
                setApplied(draft);
                setOpen(false);
              }}
            >
              Apply
            </Button>
          </Stack>
        </Stack>
      </Popover>
      <Stack gap={1}>
        {SHIPMENTS.filter((s) => applied.includes(s.depot)).map((s) => (
          <Text key={s.id} size="sm">
            {s.id} · {s.customer} · {s.depot}
          </Text>
        ))}
      </Stack>
    </Stack>
  );
}
```

### A card at the right edge

A name at the end of a row opens its card `align="end"`, so the card grows back into the page; `offset` sets it a little further off the row, and `hideOnScroll` closes it when the page scrolls instead of letting it travel along.

```tsx
import { useRef, useState } from "react";
import { Button, Popover, Stack, Text } from "@umriss-ui/core";

export default function ACardAtTheEdge() {
  const anchor = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <Stack direction="row" justify="space-between" align="center" gap={3}>
      <Text size="sm">INC-1048 · Checkout fails for card payments</Text>
      <Button ref={anchor} size="sm" variant="ghost" onClick={() => setOpen((o) => !o)} aria-expanded={open}>
        Maya Lindgren
      </Button>
      <Popover open={open} onOpenChange={setOpen} anchorRef={anchor} align="end" offset={8} hideOnScroll role="dialog" ariaLabel="Maya Lindgren" width={260}>
        <Stack gap={1} style={{ padding: "var(--u-space-4)" }}>
          <Text size="sm" weight="medium">
            Maya Lindgren
          </Text>
          <Text size="sm" tone="secondary">
            Incident lead · Payments team
          </Text>
          <Text size="xs" tone="muted">
            On call until Friday, 18:00
          </Text>
        </Stack>
      </Popover>
    </Stack>
  );
}
```

## When to use something else

- A list of actions → Menu
- A sentence of explanation → Tooltip
- The user must answer before going on → Modal

## Keyboard

| Key | Action |
|---|---|
| `Escape` | Asks to close; with `restoreFocus` the focus returns to the trigger. |

## API

### `PopoverProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `open` *required* | `boolean` | — | Whether the surface stands. Controlled: the popover never opens itself.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |
| `onOpenChange` *required* | `(open: boolean) => void` | — | Reports every wish to change the state – outside click, Escape, scrolling. Whether it closes as a result is the caller's decision.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |
| `anchorRef` *required* | `RefObject<HTMLElement \| null>` | — | Trigger the panel hangs from (the reference for the position).<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |
| `focusRef` | `RefObject<HTMLElement \| null>` | `anchorRef` | Element that receives the focus on closing. Needed where the anchor is a non-focusable shell and the actual trigger sits inside it. |
| `insideRefs` | `ReadonlyArray<RefObject<HTMLElement \| null>>` | — | Further elements whose click does not count as an outside click (e.g. the clear ×). |
| `align` | [`Align`](https://romanhaendler.github.io/umriss-ui/core/popover/#type-Align)<br>`"start" \| "end" \| "center"` | `"start"` | Where the surface points as long as there is room. Where there is none, it flips to the other side and clamps to the edge of the window.<br>Shown in: [A card at the right edge](https://romanhaendler.github.io/umriss-ui/core/popover/#a-card-at-the-edge) |
| `offset` | `number` | — | Distance between trigger and surface, in pixels.<br>Shown in: [A card at the right edge](https://romanhaendler.github.io/umriss-ui/core/popover/#a-card-at-the-edge) |
| `width` | `"anchor" \| "auto" \| number` | `"auto"` | "anchor" takes over the width of the trigger, a number fixes it.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |
| `minWidth` | `number` | — | Minimum width when the width comes from the anchor.<br>Shown in: [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) |
| `restoreFocus` | `boolean` | `true` | Give the focus back to the trigger on closing.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) |
| `hideOnScroll` | `boolean` | `false` | Close on scrolling instead of travelling along – for tooltips.<br>Shown in: [A card at the right edge](https://romanhaendler.github.io/umriss-ui/core/popover/#a-card-at-the-edge) |
| `id` | `string` | — | Fixed id of the surface – for `aria-controls` on the trigger. |
| `children` *required* | `ReactNode` | — | What stands in the surface.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| `role` | `"dialog" \| "listbox" \| "menu" \| "tooltip"` | — | Role of the surface. Omit where an inner element carries it.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |
| `ariaLabel` | `string` | — | Accessible name of the surface. Needed as soon as it carries a `role`: a named role without a name is a riddle for a screen reader.<br>Shown in: [An anchored surface](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor), [Filter a list from a panel](https://romanhaendler.github.io/umriss-ui/core/popover/#a-filter-panel) and 1 more |

#### Styling

| Prop | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Additional class on the surface. |

### Types on this page

#### `Align`

Where the panel lines up with its anchor along the side it stands on.

```ts
type Align = "start" | "end" | "center";
```

## Known limits

- No padding and no inner layout: it brings the overlay's surface - ground, corners, shadow - and the rest is yours. A `className` restates the surface where one needs another.
- It does not trap focus; a surface the user must answer is a [Modal](https://romanhaendler.github.io/umriss-ui/core/modal/).

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
