Popover
A floating panel anchored to a trigger, for a small form or detail.
Vibra draws the panel as the kit's elev-1 floating layer — popover plane, hairline ring, --shadow-float — and fades it in over --duration-base without shadcn's zoom, sliding 4px from its side rather than 8. PopoverHeader, PopoverTitle and PopoverDescription name it: the panel is a dialog, labelled by its title and described by its description. It opens on a click, a tap or Enter and stays until it is dismissed, so it is where information goes that a reader on a phone needs — a tooltip never opens under a finger. Escape and a click outside close it, and the focus returns to its trigger.
Install
npx shadcn@latest add @vibra/popoverNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover"
export default function PopoverDemo() {
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>Set a revenue goal</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Q4 revenue goal</PopoverTitle>
<PopoverDescription>Drawn as a line on the revenue chart.</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="revenue-goal">Monthly recurring revenue (USD)</Label>
<Input id="revenue-goal" inputMode="numeric" defaultValue="55000" className="tabular-nums" />
</div>
</PopoverContent>
</Popover>
)
}Notifications
The bell's name carries the unread count, not only its dot; unread rows say so in words, and marking them read is announced.
"use client"
import * as React from "react"
import { BellIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
const ITEMS = [
{ id: 1, text: "Harbour Logistics paid INV-2041", when: "4 min ago" },
{ id: 2, text: "Tomás Ortega invited you to Q4 launch", when: "1 h ago" },
{ id: 3, text: "Keystone Dental's card was declined", when: "3 h ago" },
{ id: 4, text: "September's board pack is ready", when: "Yesterday" },
]
// The bell says how many are unread in its name, not only in the dot, and
// so does the panel's title. Unread rows lead with a dot and the word
// "Unread" for a screen reader; marking them all read updates both counts.
export default function PopoverNotifications() {
const [unread, setUnread] = React.useState([1, 2, 3])
const [marked, setMarked] = React.useState(0)
const count = unread.length
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" size="icon" className="relative" aria-label={count ? `Notifications, ${count} unread` : "Notifications"} />}>
<BellIcon aria-hidden="true" />
{count ? <span aria-hidden="true" className="absolute -end-1 -top-1 flex size-4 items-center justify-center rounded-full bg-foreground text-avatar text-background tabular-nums">{count}</span> : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-80 gap-1 p-1.5">
<PopoverHeader className="flex-row items-center justify-between px-1.5 py-1">
<PopoverTitle>{count ? `${count} unread` : "All caught up"}</PopoverTitle>
<Button
variant="ghost"
size="xs"
disabled={!count}
focusableWhenDisabled
onClick={() => {
setMarked(count)
setUnread([])
}}
>
Mark all as read
</Button>
</PopoverHeader>
<ul className="flex flex-col">
{ITEMS.map((item) => (
<li key={item.id} className="flex items-start gap-2 rounded-md px-1.5 py-2 text-sm">
<span aria-hidden="true" className={cn("mt-1.5 size-1.5 shrink-0 rounded-full", unread.includes(item.id) ? "bg-foreground" : "bg-transparent")} />
<span className="flex min-w-0 flex-col">
{unread.includes(item.id) ? <span className="sr-only">Unread: </span> : null}
<span className={cn(unread.includes(item.id) && "font-medium")}>{item.text}</span>
<span className="text-xs text-muted-foreground">{item.when}</span>
</span>
</li>
))}
</ul>
<p role="status" className="sr-only">
{marked ? `${marked} notifications marked as read.` : ""}
</p>
</PopoverContent>
</Popover>
)
}Share
A report's link with a button that copies it, and who can open it, said right beside the link.
"use client"
import * as React from "react"
import { Share2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const ACCESS = [
{ value: "workspace", label: "Anyone at Northwind" },
{ value: "invited", label: "Only people invited" },
]
const LINK = "https://app.northwind.example/r/q3-revenue"
// Sharing a report from its header. The panel is a dialog: it opens with the
// focus on the link, its title names it, and Escape hands the focus back to
// Share. Who can open the link is said right beside the link itself.
export default function PopoverShare() {
const [access, setAccess] = React.useState("workspace")
return (
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
<Share2Icon aria-hidden="true" data-icon="inline-start" />
Share
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<PopoverHeader>
<PopoverTitle>Share Q3 revenue</PopoverTitle>
<PopoverDescription>The report opens as it looks now, with its filters.</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="popover-share-link">Link</Label>
<div className="flex items-center gap-1.5">
<Input id="popover-share-link" readOnly value={LINK} className="font-mono text-xs" onFocus={(event) => event.currentTarget.select()} />
<CopyButton value={LINK} label="Copy link" variant="outline" size="icon" />
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span id="popover-share-access" className="text-sm text-muted-foreground">
Who can open it
</span>
<Select items={ACCESS} value={access} onValueChange={(value) => setAccess(String(value))}>
<SelectTrigger size="sm" aria-labelledby="popover-share-access">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
)
}Explains a term
A definition that opens on a tap, a click or Enter and stays — the touch-proof twin of a tooltip — named by its title and described by the sentence.
import { InfoIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
// A definition a reader may need on a phone: a tooltip never opens under a
// finger, so this one opens on a tap, a click or Enter, and stays until it is
// dismissed. The panel is named by its title and described by the sentence,
// so a screen reader hears both as it opens.
export default function PopoverInfo() {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
Net revenue retention
<Popover>
<PopoverTrigger render={<Button variant="ghost" size="icon-xs" aria-label="What net revenue retention means" />}>
<InfoIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent side="top" className="w-64">
<PopoverHeader>
<PopoverTitle>Net revenue retention</PopoverTitle>
<PopoverDescription>
This September's revenue from last September's customers, upgrades included, divided by what they paid then.
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
</div>
<p className="type-numeral text-3xl">112%</p>
</div>
)
}A filter
An orders table's Amount chip: the chip says what is applied, a minimum above the maximum is refused in the panel, and Apply returns the focus to the chip.
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
type Range = { min: string; max: string }
const format = (range: Range) =>
range.min && range.max ? `$${range.min}–$${range.max}` : range.min ? `$${range.min} or more` : range.max ? `Up to $${range.max}` : "Any"
// A filter chip on an orders table. The chip says what is applied, so the
// table's state reads without opening it. Apply closes the panel and hands
// the focus back to the chip; a minimum above the maximum is refused in the
// panel, beside the fields, and nothing is applied.
export default function PopoverFilter() {
const [open, setOpen] = React.useState(false)
const [applied, setApplied] = React.useState<Range>({ min: "500", max: "" })
const [error, setError] = React.useState("")
function apply(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const next = { min: String(data.get("min")).trim(), max: String(data.get("max")).trim() }
if (next.min && next.max && Number(next.min) > Number(next.max)) return setError("The minimum is above the maximum.")
setError("")
setApplied(next)
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setError("")
}}
>
<PopoverTrigger render={<Button variant="outline" aria-label={`Amount: ${format(applied)}`} />}>
Amount
<span className="text-muted-foreground tabular-nums">{format(applied)}</span>
<ChevronDownIcon aria-hidden="true" data-icon="inline-end" />
</PopoverTrigger>
<PopoverContent align="start" className="w-64">
<PopoverTitle>Order amount</PopoverTitle>
{/* Keyed by what is applied, so the fields start from it each time rather than changing their defaults under Base UI. */}
<form key={format(applied)} onSubmit={apply} noValidate className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="popover-filter-min">Minimum</Label>
<Input id="popover-filter-min" name="min" inputMode="numeric" defaultValue={applied.min} aria-describedby={error ? "popover-filter-error" : undefined} className="tabular-nums" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="popover-filter-max">Maximum</Label>
<Input id="popover-filter-max" name="max" inputMode="numeric" defaultValue={applied.max} aria-describedby={error ? "popover-filter-error" : undefined} className="tabular-nums" />
</div>
</div>
<p id="popover-filter-error" role="alert" className="text-xs text-danger empty:hidden">
{error}
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setOpen(false)
setError("")
}}
>
Cancel
</Button>
<Button type="submit" size="sm">
Apply
</Button>
</div>
</form>
</PopoverContent>
</Popover>
)
}A tour in steps
Three tips in one panel: each is read out as it replaces the last, Next keeps the focus, and Done closes the tour.
"use client"
import * as React from "react"
import { SparklesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
const STEPS = [
{ title: "Pin a report", body: "Pinned reports open first on your dashboard, above the week's figures." },
{ title: "Schedule an email", body: "Any report can go out on a schedule, as a PDF, to anyone at Northwind." },
{ title: "Compare periods", body: "Compare in the chart's menu draws last period as a line under this one." },
]
// Three tips in one panel. The tip's words sit in a polite live region, so
// each new one is read out as it replaces the last, its number first. The
// panel opens with the focus on Next (initialFocus), which keeps it from step
// to step; the last step's Done closes the panel and returns the focus to the
// button that opened it.
export default function PopoverTour() {
const [open, setOpen] = React.useState(false)
const [step, setStep] = React.useState(0)
const next = React.useRef<HTMLButtonElement>(null)
const last = step === STEPS.length - 1
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (next) setStep(0)
}}
>
<PopoverTrigger render={<Button variant="outline" />}>
<SparklesIcon aria-hidden="true" data-icon="inline-start" />
What's new in Reports
</PopoverTrigger>
<PopoverContent side="bottom" initialFocus={next} className="w-72">
<PopoverHeader aria-live="polite">
<p aria-hidden="true" className="text-xs text-muted-foreground tabular-nums">
{step + 1} of {STEPS.length}
</p>
<PopoverTitle>
<span className="sr-only">
Tip {step + 1} of {STEPS.length}:
</span>{" "}
{STEPS[step].title}
</PopoverTitle>
<PopoverDescription>{STEPS[step].body}</PopoverDescription>
</PopoverHeader>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" disabled={step === 0} focusableWhenDisabled onClick={() => setStep(step - 1)}>
Back
</Button>
<Button ref={next} size="sm" onClick={() => (last ? setOpen(false) : setStep(step + 1))}>
{last ? "Done" : "Next"}
</Button>
</div>
</PopoverContent>
</Popover>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open / onOpenChange | boolean / (open: boolean) => void | — | Control it to close it from inside — an Apply that validates first, a tour's last step. |
| PopoverContent.initialFocus | React.RefObject<HTMLElement> | — | Where the focus goes as it opens — the first control by default; a tour's Next, not its disabled Back. |
| PopoverContent.side / align | "top" | "bottom" | "inline-start" | "inline-end" | … / "start" | "center" | "end" | "bottom" / "center" | Where it opens against the trigger; Base UI flips it when there is no room. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 elev-1 p-2.5 text-sm text-popover-foreground outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-left-1 data-[side=inline-start]:slide-in-from-right-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}