Components

Popover

A transient surface attached to a control, holding information or a small task.

Markdown

A popover is a transient surface attached to a control, holding information or a small task — a few settings, a short form — related to that control.

A small task

Rename opens a popover with the name selected, ready to type over. Return renames; Escape leaves it as it was.

Quarterly review.key
Code
"use client";

import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@publictheta/ui/components/popover/popover.tsx";
import { useState } from "react";

export default function RenamePopover() {
  const [name, setName] = useState("Quarterly review.key");
  const [draft, setDraft] = useState(name);
  const [open, setOpen] = useState(false);

  return (
    <div className="flex items-center gap-3">
      <span className="text-sm font-medium text-fg-strong">{name}</span>
      <Popover
        open={open}
        onOpenChange={(next) => {
          if (next) setDraft(name);
          setOpen(next);
        }}
      >
        <PopoverTrigger>Rename</PopoverTrigger>
        <PopoverContent aria-labelledby="rename-title">
          <form
            className="flex flex-col gap-3"
            onSubmit={(event) => {
              event.preventDefault();
              if (draft.trim() !== "") setName(draft.trim());
              setOpen(false);
            }}
          >
            <h3 id="rename-title" className="font-medium text-fg-strong">
              Rename
            </h3>
            <input
              aria-label="Name"
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              onFocus={(event) => {
                // Select the name without its extension, as file browsers do.
                const dot = event.target.value.lastIndexOf(".");
                event.target.setSelectionRange(0, dot > 0 ? dot : event.target.value.length);
              }}
              className="h-8 rounded-md border border-border px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-focus-ring pointer-coarse:h-11"
            />
            <div className="flex justify-end gap-2">
              <PopoverClose className="h-8 rounded-md px-3 text-sm text-fg hover:bg-surface-muted pointer-coarse:h-11">
                Cancel
              </PopoverClose>
              <button
                type="submit"
                className="h-8 rounded-md bg-selected px-3 text-sm text-on-selected outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 pointer-coarse:h-11"
              >
                Rename
              </button>
            </div>
          </form>
        </PopoverContent>
      </Popover>
    </div>
  );
}

When to use

Use a popover for a task that is small and closely tied to one control, where leaving the current view would be disproportionate. When the content is a list of commands, use a menu. When the task demands a decision before anything else can continue, use a dialog.

Best practices

Keep it small. A popover should hold one task. If it needs scrolling or navigation of its own, it wants to be a view.

Name it. A popover is a dialog: give it a name, from a visible heading or a label, so it is announced for what it is.

Commit changes as they are made, or offer a clear way to finish. Closing a popover by Escape or a press outside should not lose work that people expect to keep; where changes must be confirmed, provide the button that confirms them.

Open it from a control that says so. The control states that it opens a dialog and whether it is open.

Give a close button a name. A button that closes the popover and shows only a mark — a cross — is named in words, in the language of the interface. One that carries its own label, such as Done or Cancel, is styled as the task's other buttons are.

Platform considerations

Pointer and keyboard. The popover appears beside its control. Focus moves to its first control, Tab moves through its contents, and Escape closes it and returns focus to the control that opened it. It needs no close button of its own, and has none unless it is given one.

Touch, on a phone. The popover rises from the bottom edge as a sheet, across the full width, above a dimmed interface. There the page behind is inert and there is no Escape key, so a popover always has a button that closes it: one that was given none has a close button at its top corner, clear of its content and at least 44 CSS pixels across. It comes last in the order of focus, so that opening the popover still puts focus on its own first control.

See also Overlays.

Behaviour

Semantics

The control states that it opens a dialog, and whether it is open
const screen = await render(<Rename />);
await expect.element(trigger(screen)).toHaveAttribute("aria-haspopup", "dialog");
await expect.element(trigger(screen)).toHaveAttribute("aria-expanded", "false");
await userEvent.click(trigger(screen));
await expect.element(trigger(screen)).toHaveAttribute("aria-expanded", "true");
The popover is a dialog with its name
const screen = await render(<Rename />);
await userEvent.click(trigger(screen));
await expect.element(dialog(screen)).toBeVisible();
Has no accessibility violations when open
const screen = await render(<Rename />);
await userEvent.click(trigger(screen));
await expectAccessible(screen.container);

Focus

Opening moves focus to its first control
const screen = await render(<Rename />);
await userEvent.click(trigger(screen));
await expect.element(screen.getByRole("textbox", { name: "Name" })).toHaveFocus();
Tab moves through its controls
const screen = await render(<Rename />);
await userEvent.click(trigger(screen));
await userEvent.tab();
await expect.element(screen.getByRole("textbox", { name: "Tags" })).toHaveFocus();
A close button closes it and returns focus to the control that opened it
const screen = await render(<Rename />);
await userEvent.click(trigger(screen));
await userEvent.click(screen.getByRole("button", { name: "Done" }));
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(trigger(screen)).toHaveFocus();
Escape closes it and returns focus to the control that opened it
const screen = await render(<Rename />);
await userEvent.click(trigger(screen));
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(trigger(screen)).toHaveFocus();

Closing

A close button given no label is an icon button named in words, and closes it
const screen = await render(<Details />);
await userEvent.click(screen.getByRole("button", { name: "Details" }));
const close = screen.getByRole("button", { name: "Close" });
await expect.element(close).toBeVisible();
await expectAccessible(screen.container);
expect(close.element().textContent).toBe("");
const box = close.element().getBoundingClientRect();
expect(box.width).toBe(box.height);
await userEvent.click(close);
await expect.poll(() => screen.getByRole("dialog", { name: "File details" }).query()).toBeNull();
On a phone, where it is a sheet, one given no close button has one all the same, last in its orderChromium · touch, WebKit · touch

Not yet written.

On a phone, one given a close button has only that oneChromium · touch, WebKit · touch

Not yet written.

With a pointer, one given no close button has noneChromium, Firefox, WebKit

Not yet written.

Presentation

With a pointer, it sits against its controlChromium, Firefox, WebKit

Not yet written.

On a phone, it rises from the bottom edge across the full widthChromium · touch, WebKit · touch

Not yet written.

API

messages names the close button in another language
const screen = await render(
  <Popover messages={{ close: "閉じる" }}>
    <PopoverTrigger>Details</PopoverTrigger>
    <PopoverContent aria-label="File details">
      <PopoverClose />
    </PopoverContent>
  </Popover>,
);
await userEvent.click(screen.getByRole("button", { name: "Details" }));
await expect.element(screen.getByRole("button", { name: "閉じる" })).toBeVisible();

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/popover.json
Source

src/components/popover/popover.tsx

"use client";

import { createContext, use, useEffect, useId, useMemo } from "react";
import type { ComponentProps, MouseEvent, ReactElement, ReactNode } from "react";
import { CloseIcon } from "../../lib/close-icon.tsx";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { overlaySurfaceClassName } from "../../lib/overlay-class.ts";
import { cn } from "../../lib/utils.ts";
import { useCloseButtons, useOverlay, useSheet } from "../../primitives/overlay.ts";
import type { Overlay, Placement } from "../../primitives/overlay.ts";
import { Button } from "../button/button.tsx";
import type { ButtonProps } from "../button/button.tsx";

/** What a popover says in words; pass `messages` to say it in another language. */
export interface PopoverMessages {
  /** The name of a close button that is given no label of its own. */
  readonly close: string;
}

const MESSAGES: PopoverMessages = { close: "Close" };

interface PopoverRoot {
  readonly overlay: Overlay;
  readonly triggerId: string;
  readonly messages: PopoverMessages;
  /** Whether it was given a close button of its own. */
  readonly closable: boolean;
  /** Count a close button, until what is returned is called. */
  readonly addClose: () => () => void;
}

const PopoverContext = createContext<PopoverRoot | null>(null);

function useRoot(name: string): PopoverRoot {
  const root = use(PopoverContext);
  if (root === null) throw new Error(`${name} must be rendered inside a Popover.`);
  return root;
}

export type PopoverProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  placement?: Placement;
  messages?: Partial<PopoverMessages>;
  children?: ReactNode;
};

export function Popover({
  open,
  defaultOpen,
  onOpenChange,
  placement,
  messages,
  children,
}: PopoverProps): ReactElement {
  const overlay = useOverlay({ open, defaultOpen, onOpenChange, placement });
  const triggerId = useId();
  const { closable, addClose } = useCloseButtons();
  const root = useMemo(
    () => ({ overlay, triggerId, messages: { ...MESSAGES, ...messages }, closable, addClose }),
    [overlay, triggerId, messages, closable, addClose],
  );
  return <PopoverContext value={root}>{children}</PopoverContext>;
}

export function PopoverTrigger({ ref, style, onClick, ...props }: ButtonProps): ReactElement {
  const { overlay, triggerId } = useRoot("PopoverTrigger");
  return (
    <Button
      id={triggerId}
      aria-haspopup="dialog"
      {...overlay.triggerProps}
      ref={mergeRefs(overlay.triggerProps.ref, ref)}
      style={{ ...overlay.triggerProps.style, ...style }}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) overlay.setOpen(!overlay.open, "first");
      }}
      {...props}
    />
  );
}

/**
 * A non-modal dialog attached to its trigger. On a phone it becomes a sheet
 * along the bottom edge, which is modal, and one given no close button has one
 * there all the same: with the page behind it inert and no Escape key,
 * assistive technology needs a control that closes it. The button comes last,
 * so that focus still opens on the popover's own first control.
 */
export function PopoverContent({
  className,
  ref,
  style,
  children,
  ...props
}: ComponentProps<"div">): ReactElement {
  const { overlay, triggerId, closable } = useRoot("PopoverContent");
  const sheet = useSheet();
  return (
    <div
      role="dialog"
      // Named by its control unless it is given a name of its own.
      aria-labelledby={props["aria-label"] === undefined ? triggerId : undefined}
      {...overlay.contentProps}
      ref={mergeRefs(overlay.contentProps.ref, ref)}
      tabIndex={-1}
      style={{ ...overlay.contentProps.style, ...style }}
      className={cn(
        overlaySurfaceClassName,
        "mt-1.5 w-72 p-4 pointer-coarse:max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]",
        // Beneath the grabber, a band the close button sits in, clear of the content.
        "pointer-coarse:max-sm:pt-2 pointer-coarse:max-sm:before:mb-8",
        className,
      )}
      {...props}
    >
      {children}
      {sheet && !closable && <CloseButton className="absolute end-1 top-1" />}
    </div>
  );
}

/**
 * A button that closes the popover. Given a label it is left to its owner to
 * style, as a form's Cancel or Done is; given none it is an icon button, named
 * from `messages`.
 */
export function PopoverClose(props: ComponentProps<"button">): ReactElement {
  const { addClose } = useRoot("PopoverClose");
  useEffect(() => addClose(), [addClose]);
  return <CloseButton {...props} />;
}

function CloseButton({ onClick, className, children, ...props }: ComponentProps<"button">): ReactElement {
  const { overlay, messages } = useRoot("PopoverClose");
  const onClose = (event: MouseEvent<HTMLButtonElement>) => {
    onClick?.(event);
    if (!event.defaultPrevented) overlay.setOpen(false);
  };
  if (children !== undefined) {
    return (
      <button type="button" onClick={onClose} className={className} {...props}>
        {children}
      </button>
    );
  }
  return (
    <Button
      variant="ghost"
      size="icon"
      aria-label={messages.close}
      onClick={onClose}
      className={className}
      {...props}
    >
      <CloseIcon />
    </Button>
  );
}