Popover
Free · MITAnchored popover with an arrow in default, soft, glass, neo and inverted surfaces, tones, sizes, radii and shadows; side and align that flip and shift to stay on screen, match-trigger-width, scale, fade, slide or spring entrances, header, close button and footer slots, and modal (focus trapped) or non-modal (Tab flows back to the page) behavior, shown as a date filter, share menu, switcher and inline note form.
Installation
$ npx shadcn@latest add https://goodui.dev/r/popover.jsonThe file lands in components/goodui/popover.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/popover works too.
Usage
import * as React from "react"
import { Calendar, ChevronDown } from "lucide-react"
import { Button } from "@/components/goodui/button"
import { Popover } from "@/components/goodui/popover"
export function DateFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [draft, setDraft] = React.useState(value)
return (
<Popover
align="start"
title="Date range"
onOpenChange={(open) => open && setDraft(value)}
trigger={
<Button variant="outline" iconLeft={<Calendar />} iconRight={<ChevronDown />}>
{value}
</Button>
}
footer={({ close }) => (
<Button size="sm" onClick={() => { onChange(draft); close() }}>
Apply
</Button>
)}
>
<Presets value={draft} onChange={setDraft} />
</Popover>
)
}When to use
- Filters next to the control that opens them: a date range, a status filter, a location switcher.
- Small actions on one item: share a link, add a note to a booking, set a reminder.
- Extra information or settings that need buttons or fields, which a tooltip can't hold.
- Account or workspace switchers that should be exactly as wide as their trigger (matchTriggerWidth).
- Picking one value from a list: use select; it has listbox keyboard behavior and form support.
- A task that must be finished or cancelled before anything else: use dialog.
- A hint on hover: use tooltip.
- A menu of commands: use dropdown-stagger; menus use arrow keys, popovers use Tab.
| If you need | Use |
|---|---|
| Filters and switchers | variant="default", align="start" |
| Inline forms that shouldn't lose input to a stray click | modal (focus trapped, page blocked until saved or dismissed) |
| Switchers and comboboxes | matchTriggerWidth with arrow={false} |
| Highlighted tips | variant="soft" with a tone |
| Dark callouts | variant="inverted" |
| Over photos or gradients | variant="glass" |
| Playful brands | variant="neo" |
Examples
Share menu that closes after an action
<Popover title="Share booking page" size="sm" align="end" trigger={<Button>Share</Button>}>
{({ close }) => <ShareTargets onShared={close} />}
</Popover>Modal mini form
<Popover
modal
side="start"
variant="soft"
tone="warning"
title="Note for Maya"
trigger={<Button size="sm" variant="ghost">Note</Button>}
footer={({ close }) => <Button size="sm" onClick={() => { save(); close() }}>Save note</Button>}
>
<textarea aria-label="Note" />
</Popover>Location switcher as wide as its trigger
<Popover matchTriggerWidth arrow={false} align="start" aria-label="Switch location" trigger={<Button className="w-56">Downtown</Button>}>
{({ close }) => <LocationList onPick={close} />}
</Popover>API reference
| Prop | Type | Default |
|---|---|---|
| trigger* The element that toggles the popover (a button). It gets aria-expanded, aria-controls and aria-haspopup. | React.ReactElement<TriggerProps> | — |
| children Body. A function receives { close } for buttons that should close the popover. | React.ReactNode | ((api: PopoverApi) => React.ReactNode) | — |
| title Header title; also names the popover for screen readers. Without it, pass aria-label. | React.ReactNode | — |
| description Muted line under the title. | React.ReactNode | — |
| footer Footer row (actions). A function receives { close }. | React.ReactNode | ((api: PopoverApi) => React.ReactNode) | — |
| showClose × button in the header. Defaults to true when there is a title. | boolean | — |
| variant Surface: default (your popover color), soft (tone tint), glass, neo or inverted (dark on light). | "default" | "soft" | "glass" | "neo" | "inverted" | "default" |
| tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "primary" |
| size Width and padding: sm 16rem, md 20rem, lg 26rem, auto fits the content. | "sm" | "md" | "lg" | "auto" | "md" |
| radius | "none" | "sm" | "md" | "lg" | "xl" | "lg" |
| shadow Drop shadow. Ignored by neo, which has its own offset shadow. | "none" | "sm" | "md" | "lg" | "xl" | "lg" |
| animation How it opens: scale (default), fade, slide, spring or none. Reduced motion uses a short fade. | "none" | "fade" | "scale" | "slide" | "spring" | "scale" |
| side Preferred side; flips to the opposite side when there is no room. | "top" | "bottom" | "left" | "right" | "start" | "end" | "bottom" |
| align Alignment along the side. start/end follow the reading direction. | "start" | "center" | "end" | "center" |
| offset Gap between trigger and popover, in px. | number | 8 |
| arrow | boolean | true |
| avoidCollisions false keeps the requested side and alignment even when the popover leaves the screen. | boolean | true |
| matchTriggerWidth Same width as the trigger (account switchers, comboboxes). Overrides size width. | boolean | false |
| modal true traps focus inside and blocks the page behind with a transparent layer. | boolean | false |
| closeOnOutsideClick | boolean | true |
| closeOnEsc | boolean | true |
| open Open state (controlled). | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | false |
| onOpenChange | (open: boolean) => void | — |
| initialFocus Element to focus on open. Default: the first focusable element in the body, else the panel. | React.RefObject<HTMLElement | null> | — |
| returnFocus Return focus to the trigger after closing with Esc, the close button or close(). | boolean | true |
| portal Render into document.body so overflow and z-index of parents can't clip it. | boolean | true |
| labels Text for built-in controls; pass translations here. | Partial<PopoverLabels> | — |
Also accepts every <div> attribute.
Accessibility
| Enter / Space | On the trigger: opens or closes the popover (native button). Focus moves to the first control in the body. |
| Tab / Shift+Tab | Non-modal: moves through the popover as if it sat right after the trigger; Tab past the last control closes it and focuses the next control on the page, Shift+Tab before the first closes it and returns to the trigger. Modal: wraps around inside. |
| Esc | Closes the popover (not a dialog behind it) and returns focus to the trigger. |
- The panel has role="dialog", is named by the title (aria-labelledby) and described by the description; without a title, pass aria-label. modal adds aria-modal="true".
- The trigger gets aria-haspopup="dialog", aria-expanded and aria-controls.
- Focus moves in on open (initialFocus, else the first control in the body, else the panel) and returns to the trigger after Esc, the close button or close(). An outside click doesn't pull focus back, so the clicked control keeps it.
- Outside-click detection follows the React tree, so popovers, selects and menus rendered in portals inside the popover don't close it.
- The × button is named by labels.close for translation. The arrow is decorative.
Motion
- scale: from 0.94 in 180 ms. slide: 6px from the trigger in 220 ms. spring: from 0.8 on a spring (stiffness 520, damping 26). fade: opacity only, 150 ms. Every variant leaves in 120 ms.
- Scale and spring grow from the arrow tip.
- Reduced motion: a 100 ms fade, no movement. animation="none" opens and closes instantly.
- Nothing renders until it opens. Position is recomputed on open, scroll, resize and when the panel or trigger changes size, not on every frame.
- The body scrolls when the space on the chosen side is smaller than the content, instead of the panel leaving the screen.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Pass the opener as trigger={<Button>…</Button>} and the body as children. Use footer for actions.
- children and footer can be functions receiving { close }; call close() after applying or saving.
- Prefer side="start" / "end" over left/right so RTL works. The popover flips and shifts by itself; pass avoidCollisions={false} only when you really need a fixed side.
- Don't put a Popover trigger inside another button. Don't use it for menus or selects.
- Give it a title or an aria-label.
FAQ
Can it be controlled?
Yes: open, defaultOpen and onOpenChange. onOpenChange(true) is a good place to reset a draft to the saved value.
Does it work inside a dialog?
Yes. It renders into document.body above the dialog, Esc closes only the popover, and clicks inside it don't count as clicks outside the dialog.
Why does the body scroll?
When the space on the chosen side is smaller than the popover, it gets a max height and the body scrolls.
Related
Modal dialog, alert dialog and sheet in one: default, soft, glass and neo surfaces in six tones; center, top, start/end/left/right and draggable bottom sheets, fullscreen, and a phone override to fullscreen or sheet; scale, slide, blur, fade or morph-from-trigger entrances; async confirm with spinner and inline error, type-to-confirm, nested dialogs that step back, focus trap and restore, and scroll lock without layout shift.
Dark, light, tone, glass and neo tooltips with sizes, shapes, shadows and an optional arrow; twelve placements that flip and shift at the screen edge, scale, fade, slide or spring entrances, rich title + keyboard-shortcut content, delay groups that open instantly after the first, follow-cursor for charts and touch long-press.
Fully custom single or multi select: outline, filled, ghost, underline, pill and neo triggers; icons, avatars, descriptions and groups; search with match highlight, chips, clear, type-ahead and full keyboard; animated menu that flips up when space runs out.
One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.
A small button such as "Add note" morphs into a floating form panel, its label sliding into the header, and shrinks back into the button on save, Esc or an outside click.
A dropdown menu springs open while its items stagger in, with a highlight that glides between items, submenus, checkbox and radio items, and full keyboard navigation.