Skip to content
design
Components

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

Swing circle 45 m
True wind 18 kn from 040°
Anchor watch at night: the swing circle explained with a link, the wind reading's source, and the drag alarm radius changed in a short form.
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.

01Opened, never poppedA popover opens from the control the crew pressed, and stays until they close it. Nothing opens one on its own.
02Reachable by every handA tap opens it on a touch screen, Enter from the keyboard, and the close button is a whole touch target.
03Detail, not the decisionWhat the crew need to act safely is on the screen. A popover explains a value or holds a short edit beside it.

When to use a popover

UseWhenExample
TooltipA name or a line of text for an element, read while the pointer rests on it.An icon-only button's name
InfoTipAn explanation the crew open on purpose, which may hold a link.What the swing circle is, beside its value
PopoverA 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
ModalA 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 a dialog named by its title, or by label when 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 modal one 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. Under prefers-reduced-motion it only fades.

Examples

A note with a title

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

Set 095°, drift 1.2 kn
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.
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

`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.
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>
  );
}
CPA limit 0.5 NM
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.
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

A short popover named by `label`, with no title and no close button: Escape or a press outside closes it.
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

The note is closed.
`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.
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

With motion, the popover fades in while it moves a few pixels away from its trigger; `motion={false}` opens and closes it at once.
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

PropTypeDefaultDescription
triggerrequiredReactElement–The control that opens the popover: an element that takes a ref and a trigger's props, such as a button.
childrenrequiredReactNode–What the popover holds: text, links, buttons or a short form.
titlestring | undefined–A heading at the top of the popover, which also names it for a screen reader; translated by the caller.
labelstring | undefined–The popover's name for a screen reader when it has no title; translated by the caller.
showCloseButtonboolean | undefined–Whether the popover draws its close button beside the title; on by default. Escape closes it either way.
openboolean | undefined–Whether the popover is open, for a caller that holds the state; pair it with onOpenChange.
defaultOpenboolean | 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.
sidePopoverSide | undefined–The side of the trigger the popover opens on; the opposite side when this one has no room.
alignPopoverAlign | undefined–Where along that side the popover lines up with the trigger.
sideOffsetnumber | undefined–The gap between the trigger and the popover, in pixels.
collisionBoundaryElement | null | undefined–The element whose edges count as having no room; the window when unset.
modalboolean | 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.
motionboolean | 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.
classNamestring | undefined–Classes added to the popover's own.
testIdstring | undefined–data-testid on the popover; its close button is ${testId}--close.
refRef<HTMLDivElement> | undefined–The popover's element.
PropTypeDefaultDescription
titlerequiredstring–What the explanation is about: its heading, which also names it, translated by the caller.
childrenrequiredReactNode–The explanation: a sentence or two, and links to read further.
labelstring | undefined–The info button's name, translated by the caller; "More about" and the title when unset.
openboolean | undefined–Whether the explanation is open, for a caller that holds the state; pair it with onOpenChange.
defaultOpenboolean | undefinedfalseWhether 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.
sidePopoverSide | undefined"top"The side of the info button the explanation opens on.
alignPopoverAlign | undefined"center"Where along that side the explanation lines up with the info button.
hoverDelaynumber | undefinedDEFAULT_HOVER_DELAYHow 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.
testIdstring | undefined–data-testid on the explanation; its info button is ${testId}--trigger.
Do
Open a popover from the control the crew press, and let them close it.
Don't
Don't put an alert's cause or what acknowledging does in a popover alone. The crew read it on the alert.

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.

  • 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.