Skip to content

Popover

A non-modal surface anchored to its trigger for small forms and details.

Status
Stable
Category
Overlays
Adoption
Not used yet
import { Popover } from "@oration/canon/components/popover";
packages/canon/src/components/popover.tsx

Open invoices

Supplier
Amount
Due date
Terms
Northwind Freight
$18,240.00
Oct 21
Net 30
Halcyon
$9,612.50
Oct 14
2/10 net 30
import { Button } from "@oration/canon/components/button";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Switch } from "@oration/canon/components/switch";import { cn } from "@oration/canon/lib/utils";import { Settings2Icon } from "lucide-react";import * as React from "react";export function Hero() {    const columns = [        { id: "supplier", label: "Supplier", locked: true },        { id: "amount", label: "Amount", locked: true },        { id: "due", label: "Due date", locked: false },        { id: "terms", label: "Terms", locked: false },        { id: "owner", label: "Owner", locked: false },    ];    const [density, setDensity] = React.useState<        "compact" | "default" | "comfortable"    >("default");    const [hidden, setHidden] = React.useState<Set<string>>(        () => new Set(["owner"]),    );    const rowHeight = { compact: "h-8", default: "h-9", comfortable: "h-11" }[        density    ];    const visible = columns.filter((column) => !hidden.has(column.id));    return (        <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-background text-left shadow-border">            <div className="flex h-10 items-center justify-between border-b border-border px-3">                <p className="text-13 font-medium">Open invoices</p>                <Popover>                    <PopoverTrigger                        render={                            <Button                                type="button"                                variant="ghost"                                size="sm"                                className="text-muted-foreground"                            />                        }                    >                        <Settings2Icon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Display                    </PopoverTrigger>                    <PopoverContent                        align="end"                        aria-label="Display options"                        className="w-64 gap-3 p-3"                    >                        <div>                            <p className="mb-1.5 text-xs font-medium text-muted-foreground">                                Row height                            </p>                            <SegmentedControl                                label="Row height"                                value={density}                                onValueChange={setDensity}                                className="grid w-full grid-cols-3"                                options={[                                    { value: "compact", label: "Compact" },                                    { value: "default", label: "Default" },                                    { value: "comfortable", label: "Spacious" },                                ]}                            />                        </div>                        <div>                            <p className="mb-1 text-xs font-medium text-muted-foreground">                                Columns                            </p>                            <ul className="flex flex-col">                                {columns.map((column) => (                                    <li key={column.id}>                                        <label className="flex h-8 items-center justify-between gap-2 rounded-md px-1 text-13 hover:bg-muted">                                            {column.label}                                            <Switch                                                size="sm"                                                checked={!hidden.has(column.id)}                                                disabled={column.locked}                                                onCheckedChange={(checked) => {                                                    const next = new Set(                                                        hidden,                                                    );                                                    if (checked)                                                        next.delete(column.id);                                                    else next.add(column.id);                                                    setHidden(next);                                                }}                                            />                                        </label>                                    </li>                                ))}                            </ul>                        </div>                    </PopoverContent>                </Popover>            </div>            <div                className="grid text-13"                style={{                    gridTemplateColumns: `repeat(${visible.length}, minmax(0, 1fr))`,                }}            >                {visible.map((column) => (                    <div                        key={column.id}                        className="flex h-8 items-center border-b border-border px-3 text-muted-foreground"                    >                        {column.label}                    </div>                ))}                {[                    {                        supplier: "Northwind Freight",                        amount: "$18,240.00",                        due: "Oct 21",                        terms: "Net 30",                        owner: "Priya Raman",                    },                    {                        supplier: "Halcyon",                        amount: "$9,612.50",                        due: "Oct 14",                        terms: "2/10 net 30",                        owner: "Jordan Lee",                    },                ].map((row) =>                    visible.map((column) => (                        <div                            key={`${row.supplier}-${column.id}`}                            className={cn(                                "flex items-center truncate border-b border-border px-3 last:border-b-0",                                rowHeight,                                column.id === "amount" && "tabular-nums",                            )}                        >                            {row[column.id as keyof typeof row]}                        </div>                    )),                )}            </div>        </div>    );}

Usage#

Popover is a small non-modal surface anchored to the button that opened it, for a handful of controls that belong to that button: display options above a grid, sharing a list, recording settings, a date picker. The page behind stays live, so it suits adjustments that apply as you make them. Most of the product's pickers are built from it. The mistake is growing it into a dialog: once it needs a title bar, a long form or a Save and Cancel footer, use a dialog or sheet.

When to use

  • For view options that apply immediately, such as row height and visible columns in a toolbar Display popover.
  • For a short form tied to one button: share a list, rename a view, set a reminder.
  • For pickers: a date on a calendar, a color, a disposition or a transfer target.
  • For secondary settings of the thing beside it, such as a meeting's recording settings.

When not to use

  • For a list of actions or options to pick one from. Use Dropdown menu
  • For a short label on hover or focus. Use Tooltip
  • To explain a term, with an optional video and article link. Use Info tip
  • For a task that needs focus, a title and a Save footer, or that must block the page. Use Dialog
  • For record details that should keep the list in view. Use Sheet
  • For choosing one value from a known list in a form. Use Select

The Hairline-and-Lift Rule

The surface takes its edge and lift from the overlay shadow and a 1px ink ring at 10%, never a CSS border. Hairline dividers are only for splitting sections inside it.

The One Filled Button Rule

A popover belongs to a view that already has its primary action. Inside it, buttons are outline or ghost, unless the popover is itself a small form whose submit is its only action.

Anatomy#

Remind Northwind Freight

Emails billing@northwindfreight.com about the missing W-9.

  1. Trigger. A button, usually outline or ghost at 28px in toolbars. It keeps its hover fill while the popover is open.
  2. Surface. Popover White, 10px corners, the overlay shadow and a 1px ink ring at 10%. 18rem wide with 10px padding and 10px gaps by default.
  3. Header. Optional. PopoverTitle in medium weight and PopoverDescription in Slate Meta, 2px apart.
  4. Content. Controls and text. Section it with hairlines by passing p-0 gap-0 and padding each section.

Examples#

Header and a short form

PopoverHeader stacks PopoverTitle and PopoverDescription, which also label and describe the dialog. Control open so submitting can close it, then confirm with a toast.

import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import {  Popover,  PopoverContent,  PopoverDescription,  PopoverHeader,  PopoverTitle,  PopoverTrigger,} from "@oration/canon/components/popover";import { toast } from "@oration/canon/components/toast";import { BellIcon } from "lucide-react";import * as React from "react";export function WithHeader() {    const [open, setOpen] = React.useState(false);    const [date, setDate] = React.useState("Friday, Oct 2");    const id = React.useId();    return (        <Popover open={open} onOpenChange={setOpen}>            <PopoverTrigger render={<Button type="button" variant="outline" />}>                <BellIcon data-icon="inline-start" aria-hidden="true" />                Remind supplier            </PopoverTrigger>            <PopoverContent>                <PopoverHeader>                    <PopoverTitle>Remind Northwind Freight</PopoverTitle>                    <PopoverDescription>                        Emails billing@northwindfreight.com about the missing                        W-9.                    </PopoverDescription>                </PopoverHeader>                <form                    className="flex gap-2"                    onSubmit={(event) => {                        event.preventDefault();                        setOpen(false);                        toast.add({                            type: "success",                            title: "Reminder scheduled",                            description: `Northwind Freight gets an email on ${date}.`,                        });                    }}                >                    <label htmlFor={id} className="sr-only">                        Remind on                    </label>                    <Input                        id={id}                        value={date}                        onChange={(event) => setDate(event.target.value)}                        className="h-8"                    />                    <Button type="submit">Schedule</Button>                </form>            </PopoverContent>        </Popover>    );}

Sections

For more than one group of controls, pass p-0 gap-0, pad each section and split them with hairlines, as the share popover does.

import { Button } from "@oration/canon/components/button";import {  Popover,  PopoverContent,  PopoverDescription,  PopoverTitle,  PopoverTrigger,} from "@oration/canon/components/popover";import { toast } from "@oration/canon/components/toast";import { LinkIcon, UsersIcon } from "lucide-react";import * as React from "react";export function Sections() {    const [people, setPeople] = React.useState([        { name: "Maya Okafor", access: "Owner" },        { name: "Priya Raman", access: "Can edit" },        { name: "Wen Zhou", access: "Can view" },    ]);    return (        <Popover>            <PopoverTrigger                render={<Button type="button" variant="outline" size="sm" />}            >                <UsersIcon data-icon="inline-start" aria-hidden="true" />                Share            </PopoverTrigger>            <PopoverContent align="end" className="w-80 gap-0 p-0">                <div className="border-b border-border p-3">                    <PopoverTitle>Share Priority suppliers</PopoverTitle>                    <PopoverDescription className="text-13">                        A list of 48 suppliers paid by ACH.                    </PopoverDescription>                </div>                <ul className="flex flex-col p-2">                    {people.map((person) => (                        <li                            key={person.name}                            className="flex h-9 items-center justify-between gap-2 px-1 text-13"                        >                            {person.name}                            {person.access === "Owner" ? (                                <span className="text-muted-foreground">                                    Owner                                </span>                            ) : (                                <Button                                    type="button"                                    variant="ghost"                                    size="xs"                                    className="text-muted-foreground"                                    onClick={() => {                                        setPeople((current) =>                                            current.filter(                                                (entry) =>                                                    entry.name !== person.name,                                            ),                                        );                                        toast.add({                                            title: `Removed ${person.name}`,                                            description:                                                "They no longer have access to this list.",                                        });                                    }}                                >                                    Remove                                </Button>                            )}                        </li>                    ))}                </ul>                <div className="flex items-center justify-between gap-2 border-t border-border p-3">                    <span className="text-xs text-muted-foreground">                        Anyone in Cedarline can view                    </span>                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() =>                            toast.add({                                title: "Link copied",                                description:                                    "Anyone in Cedarline can view this list.",                            })                        }                    >                        <LinkIcon data-icon="inline-start" aria-hidden="true" />                        Copy link                    </Button>                </div>            </PopoverContent>        </Popover>    );}

Alignment

Popovers open below and centered. Triggers at the right of a toolbar use align="end" so the surface stays over the page; side moves it to another edge, and it flips on its own near the viewport edge.

import { Button } from "@oration/canon/components/button";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { CalendarClockIcon } from "lucide-react";export function Alignment() {    const sides = [        { label: "Start", align: "start" as const },        { label: "Center", align: "center" as const },        { label: "End", align: "end" as const },    ];    return (        <>            {sides.map((side) => (                <Popover key={side.label}>                    <PopoverTrigger                        render={                            <Button type="button" variant="outline" size="sm" />                        }                    >                        <CalendarClockIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Align {side.label.toLowerCase()}                    </PopoverTrigger>                    <PopoverContent                        align={side.align}                        aria-label={`Payment run, aligned ${side.label.toLowerCase()}`}                        className="w-56 text-13"                    >                        The Friday payment run leaves at 2:00 PM CT, aligned to                        the {side.label.toLowerCase()} of its trigger.                    </PopoverContent>                </Popover>            ))}        </>    );}

States#

States
StateTreatment
ClosedNot rendered. The trigger shows aria-expanded="false".
OpeningFades in from a 0.97 scale and slides 8px from the trigger side over 160ms on the house ease-out, growing from the trigger (--transform-origin).
OpenThe trigger has aria-expanded="true" and data-popup-open, so outline and ghost buttons keep their Well Gray fill.
ClosingFades out toward a 0.95 scale in 110ms, faster than it opened.
Reduced motionScale and slide are zeroed; only the fade remains.

Behavior#

  • Built on Base UI Popover. Click the trigger to toggle it; Escape or a click outside closes it.
  • Opening moves focus to the first focusable element inside, or to the popup itself when opened by touch so the keyboard doesn't jump up. Closing returns focus to the trigger. Change either with initialFocus and finalFocus.
  • Non-modal by default: the page stays scrollable and clickable. modal locks scroll and blocks outside clicks; "trap-focus" also traps focus.
  • Uncontrolled by default. There is no close part exported, so to close after a submit, control it with open and onOpenChange.
  • It portals to the body, opens below the trigger 4px away and centered, and flips or shifts to stay on screen. Toolbars on the right use align="end".
  • openOnHover on the trigger also opens it on hover after delay (300ms by default), as Info tip does.
  • Motion is shared with menus, selects and comboboxes: 160ms in from 0.97 and 110ms out.

Do and don't#

Rename view

Do. Keep it to one job: a few controls that apply as you change them, or one short form.

Edit view

Don't. Build a dialog inside a popover, with a title bar, a long form and Save and Cancel. It loses focus management and the page behind stays live.

Recording

Record external meetings automatically.

Do. Split sections with a hairline and pad each one, with p-0 gap-0 on the content.

Recording

Record external meetings automatically.
Don't. Nest a bordered card inside the popover to group controls. That is a card in a card.

Content#

  • The trigger names what's inside: Display, Share, Recording settings. Not Options or More.
  • Titles are short nouns in sentence case; descriptions are one sentence on what the settings apply to.
  • Settings that apply immediately need no Save. Confirm with a toast when the effect isn't visible on the page.

Accessibility#

  • The trigger gets aria-haspopup="dialog" and aria-expanded; the popup is role="dialog", labelled by PopoverTitle and described by PopoverDescription.
  • Without a visible title, name the popup with aria-label, such as Display options.
  • PopoverTitle renders an h2. Pass render={<h3 />} or another element when that breaks the page outline.
  • Every control inside needs a label, visible or screen reader only.
  • Focus moves in on open and back to the trigger on close, so keyboard users never lose their place.
Keyboard interactions
KeysAction
EnterOpens or closes the popover from its trigger.
SpaceOpens or closes the popover from its trigger.
TabMoves through the controls inside.
EscCloses the popover and returns focus to the trigger.

Design tokens#

Design tokens
TokenUsed for
--popoverSurface fill
--popover-foregroundText
shadow-mdThe overlay shadow
--foregroundThe 1px ring at 10%
--muted-foregroundPopoverDescription
--radius-lg10px corners
--ease-out160ms open and 110ms close

API reference#

Popover

The root. Holds the open state.

Other props spread onto Base UI Popover.Root.

Props of Popover
PropTypeDefaultDescription
openbooleanNo defaultControls whether it is open.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when it opens or closes.
modalboolean | "trap-focus"falsetrue locks scroll and blocks outside clicks; "trap-focus" also traps focus.
onOpenChangeComplete(open: boolean) => voidNo defaultCalled after the open or close animation ends.

PopoverTrigger

The button that opens it.

Other props spread onto Base UI Popover.Trigger (<button>).

Props of PopoverTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender your own button, such as <Button variant="outline" size="sm" />.
openOnHoverbooleanfalseAlso opens on hover.
delaynumber300Hover delay in milliseconds, with openOnHover.
closeDelaynumber0Close delay after hover ends, with openOnHover.
nativeButtonbooleantrueSet false when render isn't a <button>.

PopoverContent

The surface, with its portal and positioner.

Other props spread onto Base UI Popover.Popup.

Props of PopoverContent
PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""bottom"Which side of the trigger it opens on.
sideOffsetnumber4Distance from the trigger in pixels.
align"start" | "center" | "end""center"Alignment along the trigger.
alignOffsetnumber0Offset along the alignment axis.
initialFocusboolean | RefObject | (openType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on open.
finalFocusboolean | RefObject | (closeType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on close.
classNamestringNo defaultMerged after the defaults (w-72 gap-2.5 p-2.5). Widths such as w-80 and p-0 gap-0 are common.

PopoverHeader

Stacks the title and description 2px apart.

Other props spread onto <div>.

No props of its own.

PopoverTitle

Labels the popup. Medium weight.

Other props spread onto Base UI Popover.Title (<h2>).

No props of its own.

PopoverDescription

Describes the popup. Slate Meta.

Other props spread onto Base UI Popover.Description (<p>).

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Base UI's Popover.Close isn't wrapped, so there is no way to close from inside without controlling open. Every form popover in the product repeats that state.

Enter uses the 0.97 scale from globals.css, but exit keeps zoom-out-95, so popovers shrink to 0.95 on close.

PopoverTitle is always an h2, which lands at the end of the body through the portal. Only 2 of the 17 files that render a popover use it; the rest hand-roll a <p className="text-sm font-medium"> title or have none, so the dialog has no name unless an aria-label is set.