Components
Popover
A transient surface attached to a control, holding information or a small task.
MarkdownA 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.
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.jsonSource
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>
);
}