Popover
A non-modal surface anchored to its trigger for small forms and details.
Open invoices
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 One Filled Button Rule
Anatomy#
Remind Northwind Freight
Emails billing@northwindfreight.com about the missing W-9.
- Trigger. A button, usually outline or ghost at 28px in toolbars. It keeps its hover fill while the popover is open.
- 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.
- Header. Optional.
PopoverTitlein medium weight andPopoverDescriptionin Slate Meta, 2px apart. - Content. Controls and text. Section it with hairlines by passing
p-0 gap-0and 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#
| State | Treatment |
|---|---|
| Closed | Not rendered. The trigger shows aria-expanded="false". |
| Opening | Fades 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). |
| Open | The trigger has aria-expanded="true" and data-popup-open, so outline and ghost buttons keep their Well Gray fill. |
| Closing | Fades out toward a 0.95 scale in 110ms, faster than it opened. |
| Reduced motion | Scale 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
initialFocusandfinalFocus. - Non-modal by default: the page stays scrollable and clickable.
modallocks 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
openandonOpenChange. - 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". openOnHoveron the trigger also opens it on hover afterdelay(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
Edit view
Recording
Record external meetings automatically.
p-0 gap-0 on the content.Recording
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"andaria-expanded; the popup isrole="dialog", labelled byPopoverTitleand described byPopoverDescription. - Without a visible title, name the popup with
aria-label, such as Display options. PopoverTitlerenders anh2. Passrender={<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.
| Keys | Action |
|---|---|
| Enter | Opens or closes the popover from its trigger. |
| Space | Opens or closes the popover from its trigger. |
| Tab | Moves through the controls inside. |
| Esc | Closes the popover and returns focus to the trigger. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Surface fill |
--popover-foreground | Text |
shadow-md | The overlay shadow |
--foreground | The 1px ring at 10% |
--muted-foreground | PopoverDescription |
--radius-lg | 10px corners |
--ease-out | 160ms open and 110ms close |
API reference#
Popover
The root. Holds the open state.
Other props spread onto Base UI Popover.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether it is open. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when it opens or closes. |
modal | boolean | "trap-focus" | false | true locks scroll and blocks outside clicks; "trap-focus" also traps focus. |
onOpenChangeComplete | (open: boolean) => void | No default | Called after the open or close animation ends. |
PopoverTrigger
The button that opens it.
Other props spread onto Base UI Popover.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render your own button, such as <Button variant="outline" size="sm" />. |
openOnHover | boolean | false | Also opens on hover. |
delay | number | 300 | Hover delay in milliseconds, with openOnHover. |
closeDelay | number | 0 | Close delay after hover ends, with openOnHover. |
nativeButton | boolean | true | Set false when render isn't a <button>. |
PopoverContent
The surface, with its portal and positioner.
Other props spread onto Base UI Popover.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "inline-start" | "inline-end" | "bottom" | Which side of the trigger it opens on. |
sideOffset | number | 4 | Distance from the trigger in pixels. |
align | "start" | "center" | "end" | "center" | Alignment along the trigger. |
alignOffset | number | 0 | Offset along the alignment axis. |
initialFocus | boolean | RefObject | (openType) => HTMLElement | boolean | null | void | No default | Where focus goes on open. |
finalFocus | boolean | RefObject | (closeType) => HTMLElement | boolean | null | void | No default | Where focus goes on close. |
className | string | No default | Merged 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.