Skip to content
txbrendan/ui

Popover

beta

A small panel that opens from a button, with an arrow that points back at it, for extra details or a quick form.

Preview

Effect

text

form

side

no arrow

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/popover

Use it for

  • Extra details people ask for, like shipping terms or who edited a file last.
  • A quick form that belongs to one button, like renaming a file or picking a date range.
  • Help text behind an info button. Touch screens and screen readers can open a popover, unlike a tooltip.
  • Give it a PopoverTitle. It names the popover for screen readers.
  • The panel opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on PopoverPopup so the panel keeps its colors. A container that clips its overflow can clip the panel.

Avoid it for

  • A label for an icon button. Use a tooltip.
  • A list of actions. Use a menu, which has arrow-key movement.
  • A task that must be finished or cancelled before going on. Use a dialog, which blocks the page.
  • Long content or several steps. Use a dialog or a page.

Props

  • Popover

    Root. Holds one popover's open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal.

    { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean | "trap-focus" }

  • open

    Popover. Whether it is open, controlled. Use it with onOpenChange.

    boolean

  • defaultOpen

    Popover. Whether it is open at the start, when you do not control it.

    boolean · default false

  • onOpenChange

    Popover. Called with the new open state when it opens or closes.

    (open: boolean, details) => void

  • modal

    Popover. true blocks the page and its scroll while it is open. "trap-focus" keeps focus inside and leaves the page free. Either way, put a PopoverClose inside, so touch screen readers can leave it.

    boolean | "trap-focus" · default false

  • PopoverTrigger

    Part. The button that opens it. Pass render={<Button />} to use your own button.

    ComponentProps<"button"> & { render?: ReactElement; openOnHover?: boolean }

  • PopoverPopup

    Part. The panel. It renders in a portal, above the page, while open.

    ComponentProps<"div">

  • PopoverTitle

    Part. The popover's name, in semibold. An <h2> by default. Use render={<h3 />} to fit your headings.

    ComponentProps<"h2">

  • PopoverDescription

    Part. Muted text under the title. Screen readers read it when the popover opens.

    ComponentProps<"p">

  • PopoverClose

    Part. A button that closes the popover.

    ComponentProps<"button"> & { render?: ReactElement }

  • side

    PopoverPopup. Which side of the trigger it opens on. It flips when there is no room.

    "top" | "right" | "bottom" | "left" · default "bottom"

  • align

    PopoverPopup. How it lines up with the trigger.

    "start" | "center" | "end" · default "center"

  • sideOffset

    PopoverPopup. Gap between the trigger and the panel, in pixels. The arrow sits in this gap.

    number · default 12, or 6 with no arrow

  • collisionPadding

    PopoverPopup. Least gap between the panel and the screen edge, in pixels. It keeps the panel off the edge on phones.

    number | { top?: number; right?: number; bottom?: number; left?: number } · default 8

  • arrow

    PopoverPopup. Shows a small arrow that points at the trigger.

    boolean · default true

  • container

    PopoverPopup. The element the panel opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no panel.

    HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null

  • effect

    PopoverPopup. How the panel opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none.

    "rise" | "grow" · default "rise"

  • className

    PopoverPopup. Extra classes. Later classes win, so className='w-96' replaces the width.

    string

Accessibility

  • The panel has role="dialog". PopoverTitle names it, and PopoverDescription describes it.
  • The trigger has aria-expanded, and aria-haspopup="dialog" tells screen readers what it opens.
  • Opening it moves focus to the first control inside. With no control, or when opened by touch, focus goes to the panel itself.
  • Escape closes it, and so does a click outside. Closing it puts focus back on the trigger.
  • Focus is not trapped. Tab past the last control closes it and moves on to the next control after the trigger.
  • To keep focus inside, set modal="trap-focus" on Popover. Keep a PopoverClose inside too, so touch screen reader users can get out.
  • The arrow is only a picture. Screen readers skip it.
  • The theme tests hold popover text and muted text to 4.5:1 on the popover color, and focus rings and input borders to 3:1, in light and dark.

Examples

Details behind a button (needs @txbrendan/button)

import { Button } from "@/components/ui/button";

<Popover>
  <PopoverTrigger render={<Button emphasis="outline" />}>Shipping</PopoverTrigger>
  <PopoverPopup>
    <PopoverTitle>Free shipping</PopoverTitle>
    <PopoverDescription>Orders over $50 ship free in the US.</PopoverDescription>
  </PopoverPopup>
</Popover>

A quick form (needs @txbrendan/button, input, and label)

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

function RenameFile({ name, rename }: { name: string; rename: (data: FormData) => void }) {
  return (
    <Popover>
      <PopoverTrigger render={<Button emphasis="outline" />}>Rename</PopoverTrigger>
      <PopoverPopup>
        <PopoverTitle>Rename file</PopoverTitle>
        <form action={rename} className="mt-3 flex flex-col gap-3">
          <div className="flex flex-col gap-1.5">
            <Label htmlFor="file-name">Name</Label>
            <Input id="file-name" name="name" size="sm" defaultValue={name} />
          </div>
          <div className="flex justify-end gap-2">
            <PopoverClose render={<Button size="sm" emphasis="ghost" />}>Cancel</PopoverClose>
            <Button type="submit" size="sm" tone="brand">
              Save
            </Button>
          </div>
        </form>
      </PopoverPopup>
    </Popover>
  );
}

Above the trigger, with no arrow (needs @txbrendan/button)

import { Button } from "@/components/ui/button";

<Popover>
  <PopoverTrigger render={<Button emphasis="ghost" />}>Details</PopoverTrigger>
  <PopoverPopup side="top" arrow={false}>
    <PopoverTitle>Last edited</PopoverTitle>
    <PopoverDescription>Today at 9:41 by Sam Lee.</PopoverDescription>
  </PopoverPopup>
</Popover>

Grows from its trigger (needs @txbrendan/button)

import { Button } from "@/components/ui/button";

<Popover>
  <PopoverTrigger render={<Button emphasis="outline" />}>Details</PopoverTrigger>
  <PopoverPopup effect="grow">
    <PopoverTitle>Last edited</PopoverTitle>
    <PopoverDescription>Today at 9:41.</PopoverDescription>
  </PopoverPopup>
</Popover>