Popover
betaA small panel that opens from a button, with an arrow that points back at it, for extra details or a quick form.
Preview
text
form
side
no arrow
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/popover
Use it for
- Extra details people ask for, like shipping terms or who edited a file last.
- A quick form that belongs to one button, like renaming a file or picking a date range.
- Help text behind an info button. Touch screens and screen readers can open a popover, unlike a tooltip.
- Give it a PopoverTitle. It names the popover for screen readers.
- The panel opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on PopoverPopup so the panel keeps its colors. A container that clips its overflow can clip the panel.
Avoid it for
- A label for an icon button. Use a tooltip.
- A list of actions. Use a menu, which has arrow-key movement.
- A task that must be finished or cancelled before going on. Use a dialog, which blocks the page.
- Long content or several steps. Use a dialog or a page.
Props
- Popover
Root. Holds one popover's open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal.
{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean | "trap-focus" }
- open
Popover. Whether it is open, controlled. Use it with onOpenChange.
boolean
- defaultOpen
Popover. Whether it is open at the start, when you do not control it.
boolean · default false
- onOpenChange
Popover. Called with the new open state when it opens or closes.
(open: boolean, details) => void
- modal
Popover. true blocks the page and its scroll while it is open. "trap-focus" keeps focus inside and leaves the page free. Either way, put a PopoverClose inside, so touch screen readers can leave it.
boolean | "trap-focus" · default false
- PopoverTrigger
Part. The button that opens it. Pass render={<Button />} to use your own button.
ComponentProps<"button"> & { render?: ReactElement; openOnHover?: boolean }
- PopoverPopup
Part. The panel. It renders in a portal, above the page, while open.
ComponentProps<"div">
- PopoverTitle
Part. The popover's name, in semibold. An <h2> by default. Use render={<h3 />} to fit your headings.
ComponentProps<"h2">
- PopoverDescription
Part. Muted text under the title. Screen readers read it when the popover opens.
ComponentProps<"p">
- PopoverClose
Part. A button that closes the popover.
ComponentProps<"button"> & { render?: ReactElement }
- side
PopoverPopup. Which side of the trigger it opens on. It flips when there is no room.
"top" | "right" | "bottom" | "left" · default "bottom"
- align
PopoverPopup. How it lines up with the trigger.
"start" | "center" | "end" · default "center"
- sideOffset
PopoverPopup. Gap between the trigger and the panel, in pixels. The arrow sits in this gap.
number · default 12, or 6 with no arrow
- collisionPadding
PopoverPopup. Least gap between the panel and the screen edge, in pixels. It keeps the panel off the edge on phones.
number | { top?: number; right?: number; bottom?: number; left?: number } · default 8
- arrow
PopoverPopup. Shows a small arrow that points at the trigger.
boolean · default true
- container
PopoverPopup. The element the panel opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no panel.
HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null
- effect
PopoverPopup. How the panel opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none.
"rise" | "grow" · default "rise"
- className
PopoverPopup. Extra classes. Later classes win, so className='w-96' replaces the width.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| Popover | { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean | "trap-focus" } | - | Root. Holds one popover's open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal. |
| open | boolean | - | Popover. Whether it is open, controlled. Use it with onOpenChange. |
| defaultOpen | boolean | false | Popover. Whether it is open at the start, when you do not control it. |
| onOpenChange | (open: boolean, details) => void | - | Popover. Called with the new open state when it opens or closes. |
| modal | boolean | "trap-focus" | false | Popover. true blocks the page and its scroll while it is open. "trap-focus" keeps focus inside and leaves the page free. Either way, put a PopoverClose inside, so touch screen readers can leave it. |
| PopoverTrigger | ComponentProps<"button"> & { render?: ReactElement; openOnHover?: boolean } | - | Part. The button that opens it. Pass render={<Button />} to use your own button. |
| PopoverPopup | ComponentProps<"div"> | - | Part. The panel. It renders in a portal, above the page, while open. |
| PopoverTitle | ComponentProps<"h2"> | - | Part. The popover's name, in semibold. An <h2> by default. Use render={<h3 />} to fit your headings. |
| PopoverDescription | ComponentProps<"p"> | - | Part. Muted text under the title. Screen readers read it when the popover opens. |
| PopoverClose | ComponentProps<"button"> & { render?: ReactElement } | - | Part. A button that closes the popover. |
| side | "top" | "right" | "bottom" | "left" | "bottom" | PopoverPopup. Which side of the trigger it opens on. It flips when there is no room. |
| align | "start" | "center" | "end" | "center" | PopoverPopup. How it lines up with the trigger. |
| sideOffset | number | 12, or 6 with no arrow | PopoverPopup. Gap between the trigger and the panel, in pixels. The arrow sits in this gap. |
| collisionPadding | number | { top?: number; right?: number; bottom?: number; left?: number } | 8 | PopoverPopup. Least gap between the panel and the screen edge, in pixels. It keeps the panel off the edge on phones. |
| arrow | boolean | true | PopoverPopup. Shows a small arrow that points at the trigger. |
| container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | - | PopoverPopup. The element the panel opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no panel. |
| effect | "rise" | "grow" | "rise" | PopoverPopup. How the panel opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. |
| className | string | - | PopoverPopup. Extra classes. Later classes win, so className='w-96' replaces the width. |
Accessibility
- The panel has role="dialog". PopoverTitle names it, and PopoverDescription describes it.
- The trigger has aria-expanded, and aria-haspopup="dialog" tells screen readers what it opens.
- Opening it moves focus to the first control inside. With no control, or when opened by touch, focus goes to the panel itself.
- Escape closes it, and so does a click outside. Closing it puts focus back on the trigger.
- Focus is not trapped. Tab past the last control closes it and moves on to the next control after the trigger.
- To keep focus inside, set modal="trap-focus" on Popover. Keep a PopoverClose inside too, so touch screen reader users can get out.
- The arrow is only a picture. Screen readers skip it.
- The theme tests hold popover text and muted text to 4.5:1 on the popover color, and focus rings and input borders to 3:1, in light and dark.
Examples
Details behind a button (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Popover>
<PopoverTrigger render={<Button emphasis="outline" />}>Shipping</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Free shipping</PopoverTitle>
<PopoverDescription>Orders over $50 ship free in the US.</PopoverDescription>
</PopoverPopup>
</Popover>A quick form (needs @txbrendan/button, input, and label)
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function RenameFile({ name, rename }: { name: string; rename: (data: FormData) => void }) {
return (
<Popover>
<PopoverTrigger render={<Button emphasis="outline" />}>Rename</PopoverTrigger>
<PopoverPopup>
<PopoverTitle>Rename file</PopoverTitle>
<form action={rename} className="mt-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="file-name">Name</Label>
<Input id="file-name" name="name" size="sm" defaultValue={name} />
</div>
<div className="flex justify-end gap-2">
<PopoverClose render={<Button size="sm" emphasis="ghost" />}>Cancel</PopoverClose>
<Button type="submit" size="sm" tone="brand">
Save
</Button>
</div>
</form>
</PopoverPopup>
</Popover>
);
}Above the trigger, with no arrow (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Popover>
<PopoverTrigger render={<Button emphasis="ghost" />}>Details</PopoverTrigger>
<PopoverPopup side="top" arrow={false}>
<PopoverTitle>Last edited</PopoverTitle>
<PopoverDescription>Today at 9:41 by Sam Lee.</PopoverDescription>
</PopoverPopup>
</Popover>Grows from its trigger (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Popover>
<PopoverTrigger render={<Button emphasis="outline" />}>Details</PopoverTrigger>
<PopoverPopup effect="grow">
<PopoverTitle>Last edited</PopoverTitle>
<PopoverDescription>Today at 9:41.</PopoverDescription>
</PopoverPopup>
</Popover>