Popover

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.

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

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.

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.

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.

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.

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

Keyboard

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

API

PopoverProps

NameTypeDefaultDescription
openrequiredboolean—Whether the surface stands. Controlled: the popover never opens itself.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

onOpenChangerequired(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.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

anchorRefrequiredRefObject<HTMLElement | null>—Trigger the panel hangs from (the reference for the position).

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

focusRefRefObject<HTMLElement | null>anchorRefElement that receives the focus on closing. Needed where the anchor is a non-focusable shell and the actual trigger sits inside it.
insideRefsReadonlyArray<RefObject<HTMLElement | null>>—Further elements whose click does not count as an outside click (e.g. the clear ×).
alignAlign
| "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.

Shown in: A card at the right edge

offsetnumber—Distance between trigger and surface, in pixels.

Shown in: A card at the right edge

width| "anchor"
| "auto"
| number
"auto""anchor" takes over the width of the trigger, a number fixes it.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

minWidthnumber—Minimum width when the width comes from the anchor.

Shown in: Take the width of the trigger

restoreFocusbooleantrueGive the focus back to the trigger on closing.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel

hideOnScrollbooleanfalseClose on scrolling instead of travelling along – for tooltips.

Shown in: A card at the right edge

idstring—Fixed id of the surface – for aria-controls on the trigger.
childrenrequiredReactNode—What stands in the surface.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

Accessibility · 2
NameTypeDefaultDescription
role| "dialog"
| "listbox"
| "menu"
| "tooltip"
—Role of the surface. Omit where an inner element carries it.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

ariaLabelstring—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.

Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more

Styling · 1
NameTypeDefaultDescription
classNamestring—Additional class on the surface.

Types on this page

Align

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

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

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index