Popover
A floating panel anchored to a trigger for small, interactive content such as settings, quick forms and pickers. It doesn't block the page.
import { SlidersHorizontalIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"
const dimensions = [
{ id: "width", label: "Width", value: "100%" },
{ id: "max-width", label: "Max. width", value: "1280px" },
{ id: "height", label: "Height", value: "auto" },
{ id: "max-height", label: "Max. height", value: "none" },
]
export default function PopoverDemo() {
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<SlidersHorizontalIcon data-icon="inline-start" />
Dimensions
</PopoverTrigger>
<PopoverContent className="w-80">
<PopoverHeader>
<PopoverTitle>Dimensions</PopoverTitle>
<PopoverDescription>
Set the size of the selected frame.
</PopoverDescription>
</PopoverHeader>
<FieldGroup className="gap-2.5">
{dimensions.map((dimension) => (
<Field
key={dimension.id}
orientation="horizontal"
className="grid grid-cols-[6rem_1fr]"
>
<FieldLabel
htmlFor={dimension.id}
className="font-normal text-muted-foreground"
>
{dimension.label}
</FieldLabel>
<Input
id={dimension.id}
size="sm"
defaultValue={dimension.value}
/>
</Field>
))}
</FieldGroup>
</PopoverContent>
</Popover>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/popover.jsonUsage
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"<Popover>
<PopoverTrigger render={<Button variant="outline" />}>Dimensions</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Dimensions</PopoverTitle>
<PopoverDescription>Set the size of the selected frame.</PopoverDescription>
</PopoverHeader>
{/* … */}
</PopoverContent>
</Popover>The popover is positioned by Floating UI: it opens below its trigger, flips to the other side when there isn't room, and stays inside the viewport. It enters from the trigger's direction — the popup-motion choreography shared by every prfct floating surface.
Examples
Quick input
A popover can hold a small form. Control it with open and onOpenChange so it closes after a successful submit, and confirm with a Toast.
"use client"
import * as React from "react"
import { MessageSquareIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
export default function PopoverFeedback() {
const [open, setOpen] = React.useState(false)
const [message, setMessage] = React.useState("")
function send(event: React.FormEvent) {
event.preventDefault()
setOpen(false)
setMessage("")
toast.add({ title: "Thanks for the feedback", type: "success" })
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button variant="outline" />}>
<MessageSquareIcon data-icon="inline-start" />
Feedback
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<form onSubmit={send} className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor="feedback-message">
How can we improve this page?
</FieldLabel>
<Textarea
id="feedback-message"
placeholder="Ideas, bugs, or anything that slowed you down…"
value={message}
onChange={(event) => setMessage(event.target.value)}
className="min-h-24"
/>
</Field>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Replies go to your email.
</span>
<Button type="submit" size="sm" disabled={!message.trim()}>
Send
</Button>
</div>
</form>
</PopoverContent>
</Popover>
)
}Header
PopoverHeader, PopoverTitle and PopoverDescription give a popover a title that names it for assistive technology. Use them whenever the content has more than one control.
import { SettingsIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
const options = [
{
id: "wrap",
label: "Wrap long lines",
description: "Soft-wrap at the editor width.",
checked: true,
},
{
id: "minimap",
label: "Show minimap",
description: "An overview of the file on the right.",
checked: false,
},
{
id: "format",
label: "Format on save",
description: "Runs Prettier before every save.",
checked: true,
},
]
export default function PopoverSettings() {
return (
<Popover>
<PopoverTrigger
render={
<Button variant="ghost" size="icon" aria-label="Editor settings" />
}
>
<SettingsIcon />
</PopoverTrigger>
<PopoverContent align="start" className="w-80 gap-4">
<PopoverHeader>
<PopoverTitle>Editor settings</PopoverTitle>
<PopoverDescription>
Applied to every file you open.
</PopoverDescription>
</PopoverHeader>
<Separator />
<FieldGroup className="gap-4">
{options.map((option) => (
<Field key={option.id} orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor={option.id}>{option.label}</FieldLabel>
<FieldDescription>{option.description}</FieldDescription>
</FieldContent>
<Switch
id={option.id}
size="sm"
defaultChecked={option.checked}
/>
</Field>
))}
</FieldGroup>
</PopoverContent>
</Popover>
)
}Side
side places the popover on the top, right, bottom (default) or left of its trigger. If the preferred side doesn't fit, it flips.
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover"
const sides = ["top", "right", "bottom", "left"] as const
export default function PopoverSides() {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{sides.map((side) => (
<Popover key={side}>
<PopoverTrigger
render={<Button variant="outline" className="capitalize" />}
>
{side}
</PopoverTrigger>
<PopoverContent side={side} className="w-56">
<PopoverDescription>
Opens on the{" "}
<strong className="font-medium text-foreground">{side}</strong>,
and flips when there isn’t room.
</PopoverDescription>
</PopoverContent>
</Popover>
))}
</div>
)
}Alignment
align lines the popover up with the start, center (default) or end of the trigger. Align to the start for menus that read left to right, and to the end for triggers near the right edge.
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover"
const alignments = ["start", "center", "end"] as const
export default function PopoverAlign() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{alignments.map((align) => (
<Popover key={align}>
<PopoverTrigger
render={<Button variant="outline" className="w-28 capitalize" />}
>
{align}
</PopoverTrigger>
<PopoverContent align={align} className="w-60">
<PopoverDescription>
Aligned to the{" "}
<strong className="font-medium text-foreground">{align}</strong>{" "}
of the trigger.
</PopoverDescription>
</PopoverContent>
</Popover>
))}
</div>
)
}Guidelines
When to use
- For controls that act on one element: formatting, dimensions, filters for a single column.
- For quick inputs that don't need a full dialog: a note, a rename, a date.
- For pickers: colors, emoji, dates.
When not to use
- To show a plain text label — use a Tooltip.
- To preview a link on hover — use a Hover Card.
- For a list of actions — use a Dropdown Menu, which gives you arrow-key navigation and typeahead.
- For tasks that need focus and a clear finish — use a Dialog.
Keep it small
A popover is a glance, not a destination. If its content needs to scroll, needs more than a few controls, or needs a Save button, it has outgrown the popover. Keep it under ~20rem wide and a few rows tall.
Accessibility
The trigger is a button with aria-expanded and aria-haspopup="dialog"; the popup is a non-modal dialog, labeled by PopoverTitle when present.
| Key | Behavior |
|---|---|
EnterSpace | On the trigger: opens the popover and moves focus to its first focusable element, or closes it when it is open. |
Tab | Moves through the popover's controls and on to the rest of the page. |
Esc | Closes the popover and returns focus to the trigger. |
- Non-modal by default. The page stays interactive; clicking outside or moving focus away closes the popover. Pass
modalto lock page scroll and block pointer interaction outside the popover. Focus is trapped, withmodalor withmodal="trap-focus", only when the popup contains aPopoverClose: without a way out by keyboard, Base UI lets Tab leave instead. - Name it. Use
PopoverTitlefor any popover with controls. Without a visible title, add anaria-labeltoPopoverContent. - Hover opening is opt-in.
openOnHoveron the trigger also opens it on hover, but it always opens on click and keyboard too — never make hover the only way in.
API reference
Popover
The root. Doesn't render an element. Accepts every prop of Base UI Popover.Root.
PopoverTrigger
Toggles the popover. Renders a <button>; use render to render a prfct Button. PopoverClose is its counterpart inside the popup: a <button> that closes it, rendered the same way.
PopoverContent
Renders the portal, positioner and popup.
PopoverHeader, PopoverTitle, PopoverDescription
PopoverHeader stacks the title and description. PopoverTitle renders an <h2> that labels the popover; PopoverDescription renders a <p> that describes it.