Popover React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React popover for rich content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones.
Click the trigger and the panel grows out of it on a quick spring, pointing at the trigger when it has an arrow. It flips to the other side and slides along the edge when there is no room, and stays anchored while the page scrolls. Focus moves to the first field so you can type at once (on a touch screen the panel itself takes focus, so no keyboard pops up); Escape or a click outside closes it and focus returns to the trigger; Tab past the last control (or Shift+Tab past the first) leaves it in reading order and closes it. A modal popover keeps focus inside and blocks the page until it closes. A shortcut, when set, toggles it from anywhere. Below 768px the same content opens as a bottom sheet you can drag down: the header and footer stay pinned, the body scrolls, and the footer stacks full-width 48px buttons with the primary action on top. With reduced motion it only fades.
Popover is a free React component in the React overlay components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add popover, together with Button, Drawer, Motion presets, useControllableState, useMediaQuery and 2 more and the npm packages @radix-ui/react-popover, @radix-ui/react-slot, lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add popoverUsage
Import Popover from @/components/ui/popover.
import { ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverClose } from "@/components/ui/popover"
export function PriceFilter({ onApply, onReset }: { onApply: () => void; onReset: () => void }) {
return (
<Popover
trigger={
<Button variant="soft" rightIcon={<ChevronDown />}>
Price
</Button>
}
title="Price"
description="Pick a range or type your own."
align="start"
arrow
sheetTitle="Price filter"
footer={
<>
<Button variant="ghost" size="sm" onClick={onReset}>
Reset
</Button>
<PopoverClose asChild>
<Button size="sm" onClick={onApply}>
Apply
</Button>
</PopoverClose>
</>
}
>
<p className="text-muted-foreground">Between $500 and $1,500</p>
</Popover>
)
}Props
defaultOpendefaultfalsebooleanStarts open (uncontrolled). Pass open and onOpenChange instead to control it.
openbooleanControlled open state. The root keeps it across the 768px breakpoint, so turning a tablet never closes the popover.
onOpenChange(open: boolean) => voidCalled when it opens or closes: the trigger, Escape, a click outside, focus leaving it, the shortcut or a close button.
titledefault""ReactNodeAn optional heading at the top, 15px semibold; it names the panel for screen readers. The demo shows its sample while this is empty.
descriptiondefault""ReactNodeA quieter line under the title, wired to aria-describedby.
closeLabeldefault"Close"stringaria-label of the close button, e.g. a translated “Close” for another language.
sheetTitledefault"Details"stringThe name screen readers hear while there is no title: the bottom sheet's hidden title on phones and the panel's aria-label on desktop.
triggerReactNodeThe element that opens it, rendered with asChild: a single element that takes a ref, like a Button. It gets aria-expanded, aria-controls, data-state and, with a shortcut, aria-keyshortcuts.
childrenReactNodeThe body, 14px text. It scrolls when the panel would be taller than the space on screen.
footerReactNodeActions under the body, primary last: a right-aligned row of size="sm" buttons in the panel, stacked full width with the primary on top in the sheet (buttons grow to 48px there on their own).
sidedefault"bottom""top" | "right" | "bottom" | "left"The side of the trigger it opens on. It flips to the opposite side when there is no room.
aligndefault"center""start" | "center" | "end"How it lines up with the trigger along that side: its start edge, its center or its end edge.
sideOffsetdefault8numberGap in px between the trigger and the panel (the arrow adds its own 8px).
alignOffsetdefault0numberShifts the panel along the trigger's edge, in px, from where align puts it.
sizedefault"md""sm" | "md" | "lg" | "auto"Width 256 / 288 / 384px, or auto to fit the content (at least 192px). Always at most the screen width minus 24px.
paddingdefault"md""none" | "sm" | "md" | "lg"Space inside the panel: 0 / 12 / 16 / 20px. none is for full-bleed lists and media. The sheet on phones keeps its own 20px sides (check usePopover().sheet to lay out rows for it).
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner radius 0 / 12 / 16 / 24px (full is 24px, like every floating surface). The sheet keeps its 28px top corners.
arrowdefaultfalsebooleanA 16 x 8 arrow with a rounded tip pointing at the trigger, in the panel's color with the same hairline. It keeps clear of the rounded corners and hides when it cannot point at the trigger.
showClosedefaultfalsebooleanA round X in the top corner, centered on the title's line. Escape and a click outside always close it too.
modaldefaultfalsebooleanTraps focus inside, blocks clicks on the page and locks its scroll until the popover closes. For a confirm or a form that must be finished or cancelled.
responsivedefaulttruebooleanBelow 768px it opens as the drawer's bottom sheet: handle, drag to dismiss, pinned header and footer, safe-area padding. false keeps the floating panel on phones (an autocomplete under a field).
shortcutdefault""stringA key combo in mod+k notation (⌘ on Apple devices, Ctrl elsewhere) that toggles it from anywhere on the page; the trigger gets aria-keyshortcuts. Empty binds nothing.
onOpenAutoFocus(event: Event) => voidRuns when it opens, before focus moves in. Call event.preventDefault() to keep focus where it is (an anchored suggestion list under a field).
containerHTMLElement | nullPortal target, document.body by default. The sheet renders there too.
classNamesPartial<Record<"content" | "viewport" | "header" | "title" | "description" | "body" | "footer" | "close" | "arrow", string>>Extra classes for the inner parts: the panel, its scrolling viewport (which holds the padding), the header, title, description, body, footer, the close button and the arrow. The same keys style the sheet's parts on phones.
classNamestringExtra classes for the panel (popover-content), merged last, e.g. w-(--radix-popover-trigger-width) to match an anchor. A width here never narrows the sheet.
Parts
PopoverRoot
componentCompound API: holds the state, binds the shortcut and picks the mode (a Radix popover, or the drawer below 768px). No DOM. Props: { open, defaultOpen, onOpenChange, modal, responsive, shortcut, container }.
PopoverTrigger
componentOpens and closes it; gets aria-expanded, aria-controls and aria-keyshortcuts when a shortcut is set. In the sheet it is the drawer trigger. Props: Radix Popover.Trigger props (asChild).
PopoverAnchor
componentPositions the panel against another element, such as a field, while the trigger stays elsewhere (or is left out); in the sheet it is a plain wrapper. Props: Radix Popover.Anchor props (asChild).
PopoverContent
componentThe portal and the panel (popover-content with data-side, data-align, data-size) around a scrolling viewport, plus the close button and the arrow. In the sheet, loose children become the body on their own; keep the header, body and footer as direct children (fragments are fine) so the sheet can pin them. Props: { side, align, sideOffset, alignOffset, size, radius, padding, arrow, showClose, closeLabel, sheetTitle, container, classNames } & Radix Popover.Content props.
PopoverHeader
componentThe heading block (title and description stacked, gap 4px). Renders nothing when empty; in the sheet it is the drawer header. Props: { title?, description? } & div props.
PopoverTitle
component15px semibold title that labels the panel (aria-labelledby). In the sheet it is the drawer title. Props: h2 props.
PopoverDescription
component13px muted line that describes the panel (aria-describedby). In the sheet it is the drawer description. Props: p props.
PopoverBody
componentThe body, 14px text; the scroll region in the sheet. Takes div props.
PopoverClose
componentCloses it from any element, such as an Apply button. Props: Radix Popover.Close props (asChild).
PopoverArrow
componentThe 16 x 8 arrow that arrow={true} renders, in the popover color; nothing in the sheet. Props: Radix Popover.Arrow props.
usePopover
hookThe nearest popover's state, e.g. to close it after a save or to size footer buttons for the sheet. Signature: () => { open, setOpen, sheet }.
Examples
Sides, aligns and the arrow
Each side with arrow; the pills set align for all four. It flips when there is no room. responsive={false} keeps it floating on phones.
Share panel
A read-only link with a copy button in brand soft, and share targets. showClose puts a round × in the corner.
Quick edit
The parts API with a controlled open state: focus lands in the first field, Enter saves, the save runs 1.4s and closes it.
Sizes
sm, md and lg are 256, 288 and 384px wide; auto fits the content.
Anchored to a field
PopoverAnchor pins the list under the input and w-(--radix-popover-trigger-width) matches its width. Focus stays in the field; ↓ walks the dates.
Modal confirm
modal traps focus and blocks the page until you answer. The row slides out after Delete.
Notifications
padding="none" and size="lg" for full-bleed rows, a header action, alt+n to toggle. On a phone the same parts become a sheet.
Dependencies
Files
- components/ui/popover.tsx
FAQ
Is the React Popover component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Popover?
Run npx wingo-ui@latest add popover. It also adds the Wingo UI items it builds on (Button, Drawer, Motion presets, useControllableState, useMediaQuery and 2 more) and the npm packages @radix-ui/react-popover, @radix-ui/react-slot, lucide-react and motion. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/popover.json.
How does Popover behave on phones?
Below 768px it opens as the drawer's bottom sheet: handle, drag to dismiss, pinned header and footer, safe-area padding. false keeps the floating panel on phones (an autocomplete under a field). The responsive prop controls this and is on by default.
See also
Used in
More React overlay components
Articles using this component
- React Emoji Picker: Search, Skin Tones and Phone Sheets
Component guides9 min read
- React Color Picker: HEX, RGB, HSL, Eyedropper and OKLCH
Component guides9 min read
- Mobile-First React Components: 9 Rules With Code
Mobile UI in React21 min read



