Overlays & navigation
Popover
A popover is a small non-modal panel anchored to a button: share settings, a quick form, an explanation. It renders in the browser's top layer with the native Popover API, so it is never clipped by a scrolling container and sits above dialogs, and it positions itself on any side of the trigger without a positioning library, flipping and staying inside the viewport.
<Popover align="end"> <PopoverTrigger> <Button iconLeft={<Share2 strokeWidth={1.75} />}>Share</Button> </PopoverTrigger> <PopoverContent aria-label="Share Q3 retainer" className="w-80"> <div className="flex flex-col gap-4"> <div className="flex flex-col gap-0.5"> <p className="m-0 text-[14px] font-semibold text-fg">Share “Q3 retainer”</p> <p className="m-0 text-[13px] text-muted">Anyone with the link can view the invoice.</p> </div> <div className="flex gap-2"> <Input size="sm" readOnly value="ledgerly.example/i/q3-retainer" aria-label="Share link" prefix={<Link2 strokeWidth={1.75} />} /> <Button size="icon-sm" aria-label={copied ? "Link copied" : "Copy link"} onClick={copy}> {copied ? <Check strokeWidth={1.75} /> : <Copy strokeWidth={1.75} />} </Button> </div> <div className="flex flex-col gap-2.5 border-t border-line pt-3.5"> <p className="m-0 text-eyebrow">People with access</p> {people.map((p) => ( <div key={p.name} className="flex items-center gap-2.5 text-[13px]"> <Avatar name={p.name} size="sm" decorative /> <span className="flex-1 truncate text-fg">{p.name}</span> <span className="text-muted">{p.role}</span> </div> ))} </div> <div className="flex items-center gap-2 rounded-ui bg-raised px-3 py-2 text-[12px] text-muted"> <Globe strokeWidth={1.75} className="size-4 shrink-0" /> Public link expires in 30 days. </div> </div> </PopoverContent></Popover>Usage
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; <Popover align="end"> <PopoverTrigger> <Button>Share</Button> </PopoverTrigger> <PopoverContent aria-label="Share invoice">…</PopoverContent></Popover>Examples
Share panel
Any element works as the trigger; it receives aria-expanded and aria-controls. Content is plain JSX.
<Popover align="end"> <PopoverTrigger> <Button iconLeft={<Share2 strokeWidth={1.75} />}>Share</Button> </PopoverTrigger> <PopoverContent aria-label="Share Q3 retainer" className="w-80"> <div className="flex flex-col gap-4"> <div className="flex flex-col gap-0.5"> <p className="m-0 text-[14px] font-semibold text-fg">Share “Q3 retainer”</p> <p className="m-0 text-[13px] text-muted">Anyone with the link can view the invoice.</p> </div> <div className="flex gap-2"> <Input size="sm" readOnly value="ledgerly.example/i/q3-retainer" aria-label="Share link" prefix={<Link2 strokeWidth={1.75} />} /> <Button size="icon-sm" aria-label={copied ? "Link copied" : "Copy link"} onClick={copy}> {copied ? <Check strokeWidth={1.75} /> : <Copy strokeWidth={1.75} />} </Button> </div> <div className="flex flex-col gap-2.5 border-t border-line pt-3.5"> <p className="m-0 text-eyebrow">People with access</p> {people.map((p) => ( <div key={p.name} className="flex items-center gap-2.5 text-[13px]"> <Avatar name={p.name} size="sm" decorative /> <span className="flex-1 truncate text-fg">{p.name}</span> <span className="text-muted">{p.role}</span> </div> ))} </div> <div className="flex items-center gap-2 rounded-ui bg-raised px-3 py-2 text-[12px] text-muted"> <Globe strokeWidth={1.75} className="size-4 shrink-0" /> Public link expires in 30 days. </div> </div> </PopoverContent></Popover>Placement
`placement` picks the side (start and end follow the reading direction) and `align` the edge. Popovers flip when the preferred side has no room.
{(["top", "bottom", "start", "end"] as Placement[]).map((p) => ( <Popover key={p} placement={p}> <PopoverTrigger> <Button variant="outline" className="capitalize">{p}</Button> </PopoverTrigger> <PopoverContent aria-label={`Placed ${p}`} className="w-56"> <p className="m-0 text-[13px] text-muted"> Placed <span className="font-medium text-fg">{p}</span>. It flips to the other side when there is no room. </p> </PopoverContent> </Popover>))}Quick form
Focus moves to the first field on open. PopoverClose wraps buttons that should close the panel and return focus to the trigger.
<Popover align="start"> <PopoverTrigger> <Button variant="outline">{saved ? "Edit note" : "Add note"}</Button> </PopoverTrigger> <PopoverContent aria-label="Note for INV-2041"> <div className="flex flex-col gap-3"> <Field label="Note for INV-2041" hint="Only your team sees notes."> <Textarea rows={3} value={note} onChange={(e) => setNote(e.target.value)} placeholder="Paid by bank transfer, ref. 88412" /> </Field> <div className="flex justify-end gap-2"> <PopoverClose> <Button size="sm" variant="ghost">Cancel</Button> </PopoverClose> <PopoverClose> <Button size="sm" variant="primary" onClick={() => setSaved(note)}>Save note</Button> </PopoverClose> </div> </div> </PopoverContent></Popover>Explanation
For help text that contains more than a few words, or that people need to read slowly, use a popover rather than a tooltip.
<div className="flex items-center justify-center gap-1.5 text-[14px] text-fg"> Net revenue <span className="font-mono tabular">€48,210</span> <Popover placement="top"> <PopoverTrigger> <Button size="icon-sm" variant="ghost" aria-label="How net revenue is calculated"> <Info strokeWidth={1.75} /> </Button> </PopoverTrigger> <PopoverContent aria-label="How net revenue is calculated" className="w-64"> <p className="m-0 text-[13px] leading-5 text-muted"> Paid invoices minus refunds and payment fees, in your workspace currency. Updated every hour. </p> </PopoverContent> </Popover></div>Props
Popover
placement"top" | "bottom" | "start" | "end"default: "bottom"Preferred side of the trigger. Flips when there is not enough room.align"start" | "center" | "end"default: "center"Which edge of the trigger the panel lines up with.offsetnumberdefault: 8Gap between trigger and panel, in pixels.openbooleandefault: —Controlled open state. Pair with onOpenChange.defaultOpenbooleandefault: falseInitial state when uncontrolled.onOpenChange(open: boolean) => voiddefault: —Called when the popover opens or closes.
PopoverTrigger, PopoverContent, PopoverClose
PopoverTrigger{ children, className }default: —Pass your own button element (it gets aria-haspopup, aria-expanded, aria-controls and the click handler) or plain text for a default button.PopoverContent{ aria-label, padded?, className, children }default: —The panel: 288px wide, 16px padding (padded={false} removes it). Set a width with className.PopoverClose{ children: ReactElement }default: —Wraps a button so a click closes the popover and focuses the trigger again.
Helpers
useFloating(open, anchorRef, panelRef, { placement, align, offset })default: —Shows a popover="manual" element and keeps it anchored. Used by Tooltip and Dropdown menu.computePosition(rect, w, h, placement, align, offset, rtl?)default: —The positioning math on its own: returns top, left and the final side.
Accessibility
- The panel has role="dialog" and needs a name: pass aria-label, or aria-labelledby pointing at a heading inside it.
- The trigger gets aria-haspopup="dialog", aria-expanded and aria-controls, so screen readers announce that it opens a panel and whether it is open.
- On open, focus moves to the first focusable element in the panel; Tab continues from there in reading order.
- Escape closes the popover and returns focus to the trigger. A click outside or moving focus out of the panel also closes it.
- The popover is non-modal: the rest of the page stays usable. Use a Dialog when the user must finish or cancel first.