Popover
A panel that opens from a control and stays open until it is closed, for a note, links or a short form.
On this page
Anchor watch, Cala Portals
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { InfoTip, Popover } from "@bosunkit/components/popover";
import { SectionHeading } from "@bosunkit/components/section-heading";
import { Wind } from "lucide-react";
import {
useState,
type ChangeEvent,
type CSSProperties,
type Dispatch,
type SubmitEvent,
type ReactElement,
type SetStateAction,
} from "react";
export const description: string =
"Anchor watch at Cala Portals at 23:40 UTC: an explanation of the swing circle with a link, the wind reading's " +
"source in a popover with no close button that opens to the right, and the drag alarm radius changed in a modal " +
"popover holding a short form that closes when it is saved.";
const ROW: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "var(--space-sm)",
minHeight: "var(--target-touch-size)",
};
export default function Showcase(): ReactElement {
const [radius, setRadius]: [number, Dispatch<SetStateAction<number>>] = useState<number>(45);
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("45");
const [editing, setEditing]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
function save(event: SubmitEvent<HTMLFormElement>): void {
event.preventDefault();
const meters: number = Number.parseInt(draft, 10);
if (Number.isFinite(meters) && meters > 0) {
setRadius(meters);
setEditing(false);
}
}
return (
<div style={{ width: "100%", maxWidth: 520, display: "flex", flexDirection: "column", gap: "var(--space-xs)" }}>
<SectionHeading level={3}>Anchor watch, Cala Portals</SectionHeading>
<div style={ROW}>
<span style={{ fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
Swing circle {radius} m
</span>
<InfoTip title="Swing circle">
The circle the vessel swings in around the anchor as the wind and the stream turn her. The drag alarm sounds
when the GPS position leaves it. <a href="#anchoring">How the swing circle is set</a>
</InfoTip>
<Popover
trigger={<Button variant="ghost">Alarm radius</Button>}
title="Drag alarm radius"
modal
side="bottom"
align="start"
open={editing}
onOpenChange={(open: boolean): void => {
setEditing(open);
setDraft(String(radius));
}}
>
<form onSubmit={save} style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
<FormField label="Radius" hint="45 m holds 30 m of chain in 5 m of water with room to swing.">
<Input
type="number"
inputMode="numeric"
min={10}
unit="m"
value={draft}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setDraft(event.target.value);
}}
/>
</FormField>
<Button type="submit" variant="primary">
Save radius
</Button>
</form>
</Popover>
</div>
<div style={ROW}>
<span style={{ fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
True wind 18 kn from 040°
</span>
<Popover
trigger={
<Button variant="ghost" size="lg" aria-label="Wind source">
<Wind size={18} aria-hidden="true" />
</Button>
}
label="Wind source"
showCloseButton={false}
side="right"
>
Masthead sensor, true wind computed from the GPS speed and heading. Last reading 2 s ago.
</Popover>
</div>
</div>
);
}
A popover is a panel that opens from its trigger, a control such as a button, and stays open until it is closed. A
press, a tap or Enter on the trigger opens it and moves focus to the panel, so a screen reader reads its title and
text before the first link or button inside. Escape, its close button or a press outside closes it, and focus goes
back to the trigger. It renders in the page body through a portal, carries the theme, size class and language of
the part of the page it opens from, and stacks on --bk-layer-dropdown.
InfoTip is the popover for an explanation: an info button beside a reading or a setting that opens a title, a
sentence or two and links to read further. A mouse resting on the button opens it as a tooltip opens, without
moving focus; a press or a tap opens it with focus inside, so it works the same on the helm's touch screen. The
info button keeps a whole touch target; in a dense column of short lines, size="compact" makes it match the
text beside it, so the target opens no gaps above or below its line.
When to use a popover
| Use | When | Example |
|---|---|---|
| Tooltip | A name or a line of text for an element, read while the pointer rests on it. | An icon-only button's name |
| InfoTip | An explanation the crew open on purpose, which may hold a link. | What the swing circle is, beside its value |
| Popover | A note, a source or a short form that stays open while the crew read or change it. | A reading's source; the drag alarm radius |
| Modal | A task that needs the whole screen's attention, or a choice that must be made to go on. | Confirming an anchor drop position |
Behavior
- Opening: a press, a tap or Enter on the trigger. The trigger carries
aria-expanded, and the panel is adialognamed by itstitle, or bylabelwhen it has none. - Focus: the panel takes focus as it opens; Tab moves through what it holds. A non-modal popover closes when
focus or a press goes outside it; a
modalone keeps focus inside and hides the rest of the page from assistive technology until it closes. - Closing: Escape, the close button or a press outside, and focus returns to the trigger.
- InfoTip with a mouse: resting 200 ms on the info button opens the explanation without moving focus; the mouse can move onto it to follow a link, and it closes 300 ms after the mouse leaves both. A press while it is open this way keeps it open. A finger or a pen opens it only by a tap.
- Motion: the panel fades in while it moves a few pixels away from its trigger over
--motion-fast-duration, and reverses as it closes. Underprefers-reduced-motionit only fades.
Examples
A note with a title
Code
import { Button } from "@bosunkit/components/button";
import { Popover } from "@bosunkit/components/popover";
import type { ReactElement } from "react";
export const description: string =
"A note with a title and a close button: a press on the button opens it with focus inside; Escape, the close " +
"button or a press outside closes it and gives focus back to the button.";
export default function Note(): ReactElement {
return (
<Popover trigger={<Button>About the CPA alarm</Button>} title="Closest point of approach">
The alarm sounds when another vessel is predicted to pass within 0.5 NM of yours in the next 12 minutes.
</Popover>
);
}
InfoTip
Code
import { InfoTip } from "@bosunkit/components/popover";
import type { ReactElement } from "react";
export const description: string =
"An InfoTip beside a reading: a resting mouse opens the explanation without moving focus, a press or a tap opens " +
"it with focus inside, and its link is one Tab away.";
export default function InfoTipExample(): ReactElement {
return (
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-xs)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>Set 095°, drift 1.2 kn</span>
<InfoTip title="Set and drift">
The direction the current carries the vessel and its speed, from the difference between the course and speed
through the water and over the ground. <a href="#tidal-streams">Tidal streams</a>
</InfoTip>
</div>
);
}
Sides
Code
import { Button } from "@bosunkit/components/button";
import { Popover, type PopoverSide } from "@bosunkit/components/popover";
import type { ReactElement } from "react";
export const description: string =
"`side` opens the popover above, to the right of, below or to the left of its trigger; with no room there it " +
"opens on the opposite side.";
const SIDES: readonly PopoverSide[] = ["top", "right", "bottom", "left"];
export default function Sides(): ReactElement {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
{SIDES.map((side: PopoverSide): ReactElement => (
<Popover key={side} trigger={<Button>{side}</Button>} title={`Opens ${side}`} side={side}>
Engine room fan, running since 22:10 UTC.
</Popover>
))}
</div>
);
}
Modal, with a short form
Code
import { Button } from "@bosunkit/components/button";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import { Popover } from "@bosunkit/components/popover";
import {
useState,
type ChangeEvent,
type Dispatch,
type SubmitEvent,
type ReactElement,
type SetStateAction,
} from "react";
export const description: string =
"A modal popover holding a short form: while it is open, focus stays inside it and the rest of the page is out " +
"of reach; saving closes it.";
export default function Modal(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [limit, setLimit]: [string, Dispatch<SetStateAction<string>>] = useState<string>("0.5");
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("0.5");
function save(event: SubmitEvent<HTMLFormElement>): void {
event.preventDefault();
setLimit(draft);
setOpen(false);
}
return (
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<span style={{ fontFamily: "var(--font-mono)" }}>CPA limit {limit} NM</span>
<Popover
trigger={<Button>Change</Button>}
title="CPA limit"
modal
open={open}
onOpenChange={(next: boolean): void => {
setOpen(next);
setDraft(limit);
}}
>
<form onSubmit={save} style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
<FormField label="Closest point of approach">
<Input
inputMode="decimal"
unit="NM"
value={draft}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setDraft(event.target.value);
}}
/>
</FormField>
<Button type="submit" variant="primary">
Save
</Button>
</form>
</Popover>
</div>
);
}
No close button
Code
import { Button } from "@bosunkit/components/button";
import { Popover } from "@bosunkit/components/popover";
import { Info } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"A short popover named by `label`, with no title and no close button: Escape or a press outside closes it.";
export default function NoCloseButton(): ReactElement {
return (
<Popover
trigger={
<Button size="lg" variant="ghost" aria-label="Depth source">
<Info size={18} aria-hidden="true" />
</Button>
}
label="Depth source"
showCloseButton={false}
>
Forward transducer, 0.4 m below the waterline, corrected to the keel.
</Popover>
);
}
Controlled
Code
import { Button } from "@bosunkit/components/button";
import { Popover } from "@bosunkit/components/popover";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"`open` and `onOpenChange` hand the open state to the caller: here a second button opens the note as well as its " +
"own trigger, and the line under them follows the state.";
export default function Controlled(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "var(--space-sm)" }}>
<div style={{ display: "flex", gap: "var(--space-sm)" }}>
<Popover trigger={<Button>Watch handover</Button>} title="Watch handover" open={open} onOpenChange={setOpen}>
Sanne takes the watch at 00:00 UTC. Course 215°, the Dragonera light abeam to port by 00:40.
</Popover>
<Button
variant="ghost"
onClick={(): void => {
setOpen(true);
}}
>
Show the handover note
</Button>
</div>
<span style={{ color: "var(--text-dim)" }}>{open ? "The note is open." : "The note is closed."}</span>
</div>
);
}
Motion
Code
import { Button } from "@bosunkit/components/button";
import { Popover } from "@bosunkit/components/popover";
import type { ReactElement } from "react";
export const description: string =
"With motion, the popover fades in while it moves a few pixels away from its trigger; `motion={false}` opens and " +
"closes it at once.";
export default function Motion(): ReactElement {
return (
<div style={{ display: "flex", gap: "var(--space-sm)" }}>
<Popover trigger={<Button>With motion</Button>} title="Bilge pump">
Ran 4 times in the last hour, 20 seconds each.
</Popover>
<Popover trigger={<Button>Without motion</Button>} title="Bilge pump" motion={false}>
Ran 4 times in the last hour, 20 seconds each.
</Popover>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| triggerrequired | ReactElement | – | The control that opens the popover: an element that takes a ref and a trigger's props, such as a button. |
| childrenrequired | ReactNode | – | What the popover holds: text, links, buttons or a short form. |
| title | string | undefined | – | A heading at the top of the popover, which also names it for a screen reader; translated by the caller. |
| label | string | undefined | – | The popover's name for a screen reader when it has no title; translated by the caller. |
| showCloseButton | boolean | undefined | – | Whether the popover draws its close button beside the title; on by default. Escape closes it either way. |
| open | boolean | undefined | – | Whether the popover is open, for a caller that holds the state; pair it with onOpenChange. |
| defaultOpen | boolean | undefined | – | Whether the popover starts open, when the caller does not hold the state. |
| onOpenChange | ((open: boolean, details: PopoverChangeDetails) => void) | undefined | – | Called with the open state a press, Escape, the close button or a press outside asks for, and Base UI's change
details, whose reason says which of them asked. |
| side | PopoverSide | undefined | – | The side of the trigger the popover opens on; the opposite side when this one has no room. |
| align | PopoverAlign | undefined | – | Where along that side the popover lines up with the trigger. |
| sideOffset | number | undefined | – | The gap between the trigger and the popover, in pixels. |
| collisionBoundary | Element | null | undefined | – | The element whose edges count as having no room; the window when unset. |
| modal | boolean | undefined | – | Whether the rest of the page is out of reach while the popover is open: focus stays inside it, a screen reader reads only it, and a press outside only closes it. Off by default, for a note the crew read beside what they are doing. |
| motion | boolean | undefined | – | Whether the popover moves: on by default, it fades in while it moves a few pixels away from its trigger and
reverses as it closes. Under reduced motion it only fades. false opens and closes it at once. |
| className | string | undefined | – | Classes added to the popover's own. |
| testId | string | undefined | – | data-testid on the popover; its close button is ${testId}--close. |
| ref | Ref<HTMLDivElement> | undefined | – | The popover's element. |
| Prop | Type | Default | Description |
|---|---|---|---|
| titlerequired | string | – | What the explanation is about: its heading, which also names it, translated by the caller. |
| childrenrequired | ReactNode | – | The explanation: a sentence or two, and links to read further. |
| label | string | undefined | – | The info button's name, translated by the caller; "More about" and the title when unset. |
| open | boolean | undefined | – | Whether the explanation is open, for a caller that holds the state; pair it with onOpenChange. |
| defaultOpen | boolean | undefined | false | Whether the explanation starts open, when the caller does not hold the state. |
| onOpenChange | ((open: boolean) => void) | undefined | – | Called with the open state a press, a resting mouse, Escape or a press outside asks for. |
| side | PopoverSide | undefined | "top" | The side of the info button the explanation opens on. |
| align | PopoverAlign | undefined | "center" | Where along that side the explanation lines up with the info button. |
| hoverDelay | number | undefined | DEFAULT_HOVER_DELAY | How long a mouse rests on the info button before the explanation opens, in milliseconds. |
| size | "regular" | "compact" | undefined | "regular" | The info button's size: regular keeps the full touch target, compact matches the text beside it, for a
dense column of short lines. Defaults to regular. |
| testId | string | undefined | – | data-testid on the explanation; its info button is ${testId}--trigger. |
A popover opened from inside a dialog opens above it, and one opened from a toast above the toast: it carries
data-over-dialog or data-over-toast, which lifts its layer one step over the surface it came from.
Related
- Tooltip: a short label that names an element under a resting pointer.
- Task dialog: the dialog for a task that needs the whole screen.
- Reading: the live values an InfoTip most often explains.