Floating content anchored to a trigger, for small forms, details, pickers and notifications. Positions itself automatically, flips to stay on screen, moves focus in and restores it on close, with an optional arrow.
PopoverTrigger connects the first child (any pressable, usually a Button) to the Popover after it. Popover is the positioned surface; PopoverDialog adds padding and dialog semantics so focus moves in and the content is announced.
Always wrap content in PopoverDialog
Popover on its own is just a positioned container. That's what
Menu and Select build on,
because they bring their own roles. For anything else, put a
PopoverDialog inside and name it with a PopoverTitle or an aria-label.
showArrow adds an arrow that points at the trigger and rotates with the placement. The default offset grows from 4px to 10px to make room for it. Arrows help most when the trigger is small, like an info icon.
Monthly active users
import { InfoIcon } from "lucide-react";import { Button } from "@/components/ui/button";import { Popover, PopoverDialog, PopoverTrigger,} from "@/components/ui/popover";export default function PopoverArrow() { return ( <div className="flex items-center gap-1.5 text-sm"> Monthly active users <PopoverTrigger> <Button variant="ghost" size="icon-xs" aria-label="About this metric"> <InfoIcon /> </Button> <Popover showArrow placement="top"> <PopoverDialog aria-label="About monthly active users" className="w-64 text-sm" > Unique users who signed in at least once in the last 30 days. Bots and service accounts are excluded. </PopoverDialog> </Popover> </PopoverTrigger> </div> );}
placement sets the preferred side and alignment: top, bottom, left, right, start, end, plus combinations like bottom start or top end. The default is bottom. When there isn't room, the popover flips to the opposite side (disable with shouldFlip={false}).
import { Button } from "@/components/ui/button";import { Popover, PopoverDialog, PopoverTrigger,} from "@/components/ui/popover";const placements = ["top", "right", "bottom", "left"] as const;export default function PopoverPlacement() { return ( <div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> {placements.map((placement) => ( <PopoverTrigger key={placement}> <Button variant="outline" className="capitalize"> {placement} </Button> <Popover placement={placement} showArrow> <PopoverDialog aria-label={`Popover on ${placement}`} className="w-auto px-3 py-2 text-sm" > Placed on the {placement} </PopoverDialog> </Popover> </PopoverTrigger> ))} </div> );}
PopoverDialog accepts a render function, ({ close }) => …. Wrap a React Aria Form in it to validate the fields and close the popover after a successful submit.
import { LinkIcon } from "lucide-react";import { Form } from "react-aria-components";import { Button } from "@/components/ui/button";import { Popover, PopoverDialog, PopoverTitle, PopoverTrigger,} from "@/components/ui/popover";import { TextField } from "@/components/ui/text-field";import { toast } from "@/components/ui/toast";export default function PopoverForm() { return ( <PopoverTrigger> <Button variant="outline"> <LinkIcon /> Insert link </Button> <Popover placement="bottom start"> <PopoverDialog className="w-80"> {({ close }) => ( <Form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); const url = new FormData(e.currentTarget).get("url"); close(); toast.success(`Linked to ${url}`); }} > <PopoverTitle>Insert link</PopoverTitle> <TextField label="URL" name="url" type="url" isRequired autoFocus placeholder="https://" /> <TextField label="Text to display" name="text" placeholder="Optional" /> <div className="flex justify-end gap-2"> <Button variant="ghost" size="sm" onPress={close}> Cancel </Button> <Button type="submit" size="sm"> Insert </Button> </div> </Form> )} </PopoverDialog> </Popover> </PopoverTrigger> );}
Use Popover without a trigger by passing triggerRef, isOpen and onOpenChange. It then positions itself relative to any element, here a field, while a separate link opens it.
import { useRef, useState } from "react";import { Button } from "@/components/ui/button";import { Popover, PopoverDialog, PopoverTitle } from "@/components/ui/popover";import { TextField } from "@/components/ui/text-field";export default function PopoverTriggerRef() { const fieldRef = useRef<HTMLDivElement>(null); const [isOpen, setOpen] = useState(false); return ( <div className="grid w-full max-w-xs gap-2"> <div ref={fieldRef}> <TextField label="Webhook secret" defaultValue="whsec_6f2c1e9a" isReadOnly /> </div> <Button variant="link" size="sm" className="justify-self-start px-0" onPress={() => setOpen(true)} > Where do I use this? </Button> <Popover triggerRef={fieldRef} isOpen={isOpen} onOpenChange={setOpen} placement="bottom start" showArrow > <PopoverDialog className="grid gap-2 text-sm"> <PopoverTitle>Verifying webhooks</PopoverTitle> <p className="text-muted-foreground"> Compare this secret with the signature in the{" "} <code className="font-mono text-foreground text-xs"> X-Signature </code>{" "} header of every request we send. </p> </PopoverDialog> </Popover> </div> );}
Any pressable can be the trigger, like an inline name that opens a profile card with an Avatar, a Badge and actions. Popovers open on press, not hover, so the content stays reachable for keyboard and touch users.
Reviewed by two days ago and approved the migration plan for the ledger service.
import { MailIcon, MapPinIcon, MessageSquareIcon } from "lucide-react";import { Button as AriaButton } from "react-aria-components";import { Avatar } from "@/components/ui/avatar";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Popover, PopoverDialog, PopoverTitle, PopoverTrigger,} from "@/components/ui/popover";export default function PopoverProfileCard() { return ( <p className="max-w-sm text-sm leading-relaxed"> Reviewed by{" "} <PopoverTrigger> <AriaButton className="rounded-xs font-medium text-brand underline-offset-4 outline-none data-focus-visible:ring-[3px] data-focus-visible:ring-ring/25 data-hovered:underline"> Elena Novak </AriaButton> <Popover placement="bottom start"> <PopoverDialog className="grid w-72 gap-3"> <div className="flex items-start gap-3"> <Avatar size="lg" alt="Elena Novak" fallback="EN" colorful status="online" /> <div className="min-w-0 flex-1"> <PopoverTitle>Elena Novak</PopoverTitle> <p className="mt-1 text-muted-foreground text-xs"> Staff engineer · Payments </p> </div> <Badge size="sm" color="success"> Available </Badge> </div> <ul className="grid gap-1.5 text-muted-foreground text-xs"> <li className="flex items-center gap-2"> <MapPinIcon className="size-3.5" /> Lisbon · 14:32 local time </li> <li className="flex items-center gap-2"> <MailIcon className="size-3.5" /> elena.novak@acme.dev </li> </ul> <div className="grid grid-cols-2 gap-2"> <Button variant="outline" size="sm"> View profile </Button> <Button size="sm"> <MessageSquareIcon /> Message </Button> </div> </PopoverDialog> </Popover> </PopoverTrigger>{" "} two days ago and approved the migration plan for the ledger service. </p> );}
A copyable link, a Select for access level and an AvatarGroup of collaborators. Nested overlays like the select work inside a popover; Esc closes the innermost one first.
An icon button with an unread count opens a scrollable list with "Mark all read". The trigger's aria-label includes the count, since the badge is visual only. p-0 on PopoverDialog lets the header and list run edge to edge.
A small form with a TextareaField and an emoji ToggleButtonGroup. The submit button shows isPending while the request runs, then the popover closes and a toast confirms.
import { AngryIcon, FrownIcon, LaughIcon, MessageSquareHeartIcon, SmileIcon,} from "lucide-react";import { useState } from "react";import { Form } from "react-aria-components";import { Button } from "@/components/ui/button";import { Popover, PopoverDialog, PopoverTitle, PopoverTrigger,} from "@/components/ui/popover";import { TextareaField } from "@/components/ui/textarea";import { toast } from "@/components/ui/toast";import { ToggleButton } from "@/components/ui/toggle-button";import { ToggleButtonGroup } from "@/components/ui/toggle-button-group";const moods = [ { id: "angry", label: "Very unhappy", icon: AngryIcon }, { id: "sad", label: "Unhappy", icon: FrownIcon }, { id: "happy", label: "Happy", icon: SmileIcon }, { id: "delighted", label: "Delighted", icon: LaughIcon },];export default function PopoverRecipeFeedback() { const [pending, setPending] = useState(false); return ( <PopoverTrigger> <Button variant="outline" size="sm"> <MessageSquareHeartIcon /> Feedback </Button> <Popover placement="bottom end"> <PopoverDialog className="w-80"> {({ close }) => ( <Form className="grid gap-3" onSubmit={async (e) => { e.preventDefault(); setPending(true); await new Promise((r) => setTimeout(r, 800)); setPending(false); close(); toast.success("Thanks! Your feedback was sent to the team."); }} > <PopoverTitle>Send feedback</PopoverTitle> <TextareaField aria-label="Feedback" name="message" rows={4} isRequired autoFocus placeholder="What's working, and what isn't?" /> <div className="flex items-center justify-between gap-2"> <ToggleButtonGroup variant="spaced" size="sm" aria-label="How do you feel?" > {moods.map((m) => ( <ToggleButton key={m.id} id={m.id} aria-label={m.label}> <m.icon /> </ToggleButton> ))} </ToggleButtonGroup> <Button type="submit" size="sm" isPending={pending}> Send </Button> </div> </Form> )} </PopoverDialog> </Popover> </PopoverTrigger> );}
PopoverDialog renders role="dialog". Focus moves into the popover on open (to an element with autoFocus, otherwise the popover itself) and returns to the trigger on close.
Name the dialog with PopoverTitle or aria-label. Without either, React Aria falls back to labelling it by the trigger.
The trigger gets aria-expanded and aria-controls, so screen readers announce that it opens a dialog.
Popovers are modal for assistive tech: content outside is hidden from screen readers while open. isNonModal turns this off, but only use it for patterns designed for it.
Clicking outside or pressing Esc closes the popover.