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.
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>
</>
);
}
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>
</>
);
}
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 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>
);
}
| Key | Action |
|---|---|
Escape |
Asks to close; with restoreFocus the focus returns to the trigger. |
PopoverProps| Name | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | — | 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 |
anchorRefrequired | RefObject<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 |
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| "start" | "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 |
offset | number | — | Distance between trigger and surface, in pixels. Shown in: A card at the right edge |
width | | "anchor" | "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 |
minWidth | number | — | Minimum width when the width comes from the anchor. Shown in: Take the width of the trigger |
restoreFocus | boolean | true | Give 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 |
hideOnScroll | boolean | false | Close on scrolling instead of travelling along – for tooltips. Shown in: A card at the right edge |
id | string | — | Fixed id of the surface – for aria-controls on the trigger. |
childrenrequired | ReactNode | — | 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 |
| Name | Type | Default | Description |
|---|---|---|---|
role | | "dialog" | — | 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 |
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.Shown in: An anchored surface, Take the width of the trigger, Filter a list from a panel and 1 more |
| Name | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional class on the surface. |
AlignWhere the panel lines up with its anchor along the side it stands on.
type Align = "start" | "end" | "center";className restates the surface where one needs another.What umriss deliberately does not build, and why: ADR-0032.