Skip to content

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.

SourceBase UIRegistry
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.json

Usage

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>
  )
}

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.

Reading
Wrap lines
Line numbers
DoA handful of controls that apply immediately.
Edit profile
Don’tA form that scrolls and needs saving belongs in a dialog or a sheet.

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.

KeyBehavior
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 modal to lock page scroll and block pointer interaction outside the popover. Focus is trapped, with modal or with modal="trap-focus", only when the popup contains a PopoverClose: without a way out by keyboard, Base UI lets Tab leave instead.
  • Name it. Use PopoverTitle for any popover with controls. Without a visible title, add an aria-label to PopoverContent.
  • Hover opening is opt-in. openOnHover on 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.

PropTypeDefault
open

Whether the popover is open. Use with onOpenChange to control it.

booleanNo default
defaultOpen

Whether it is open initially, when uncontrolled.

booleanfalse
onOpenChange

Called when it opens or closes.

(open: boolean, details) => voidNo default
modal

true locks page scroll and blocks outside pointer interaction. "trap-focus" leaves both alone. Either value traps focus only when the popup contains a PopoverClose.

boolean | "trap-focus"false
actionsRef

Imperative handle to close or unmount the popover.

RefObject<{ close, unmount }>No default

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.

PropTypeDefault
openOnHover

Also opens the popover when the trigger is hovered.

booleanfalse
delay

Hover delay before opening, in ms. Requires openOnHover.

number300
closeDelay

Delay before closing a popover opened on hover, in ms.

number0

PopoverContent

Renders the portal, positioner and popup.

PropTypeDefault
side

Preferred side of the trigger. Flips when it doesn't fit.

"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""bottom"
sideOffset

Distance from the trigger, in px.

number6
align

Alignment against the trigger.

"start" | "center" | "end""center"
alignOffset

Offset along the alignment axis, in px.

number0
initialFocus

Element to focus when it opens.

boolean | RefObject | (interaction) => HTMLElement | booleanNo default
finalFocus

Element to focus when it closes.

boolean | RefObject | (interaction) => HTMLElement | booleanNo default
className

Merged with the popup classes. The default width is w-72.

stringNo default

PopoverHeader, PopoverTitle, PopoverDescription

PopoverHeader stacks the title and description. PopoverTitle renders an <h2> that labels the popover; PopoverDescription renders a <p> that describes it.