Skip to content
cennge-ui
GitHubv0.3.1, changelog

Popover

A small panel anchored to its button, for controls and content that do not need the whole screen.

Cennge / cennge-ui

React UI library

  1. Focus moves into the popover and Tab cycles through it. Escape closes it and puts focus back on its button.
  2. A click anywhere else closes it and leaves focus where you clicked; the page stays usable while it is open.
  3. In Notifications the arrow keys change the level and the panel stays open; in Share, Copy link closes it.
Live: the component itself, not a picture of it.

Field marks

Import
import { Popover, PopoverTrigger, PopoverContent } from "cennge-ui";
Size
0.7 KB gzip for this import, its dependencies aside
Pattern
Non-modal dialog: role="dialog" on the panel, aria-expanded on the button
Keyboard
Tab Shift Tab Esc
State
Controlled with isOpen and onOpenChange, or uncontrolled
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Position
Four sides, three alignments; flips and slides to keep 8px from the screen's edge
Tests
end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: dialog-scroll, overlays

When to use#

Use a Popover for a few controls or a bit of content tied to one button: a share link, a notification setting, labels for an issue. It opens beside the button, leaves the rest of the page usable, and closes as soon as the reader clicks elsewhere.

Similar#

  • Tooltip shows a short text on hover and focus, with nothing inside to click.
  • HoverCard previews what a link points to when the pointer rests on it.
  • Dropdown is a menu of actions, with the arrow keys and typeahead of a menu.
  • Dialog is for a task that needs the whole screen and blocks the page until it is done.
  • DatePicker is a date field with its calendar in a popover.

PopoverClose makes any button inside close the popover. showArrow draws an arrow toward the button.

share.tsx
import { Button, Input, Popover, PopoverClose, PopoverContent, PopoverTrigger, toast } from "cennge-ui";

const link = "https://github.com/Cennge/cennge-ui";

export default function ShareLink() {
  return (
    <Popover>
      <PopoverTrigger>
        <Button color="primary">Share</Button>
      </PopoverTrigger>
      <PopoverContent showArrow aria-label="Share cennge-ui" className="flex w-80 flex-col gap-3">
        <Input label="Link" defaultValue={link} readOnly />
        <PopoverClose>
          <Button
            color="primary"
            onClick={async () => {
              await navigator.clipboard.writeText(link);
              toast("Link copied");
            }}
          >
            Copy link
          </Button>
        </PopoverClose>
      </PopoverContent>
    </Popover>
  );
}

A small form#

Control isOpen to close the popover when the form is applied. The labels are copied into a draft each time it opens, so closing it with Escape or a click outside discards the changes.

#52 Cached results outlive a model updatebug
labels.tsx
import { useState, type FormEvent } from "react";
import { Badge, Button, Checkbox, Popover, PopoverContent, PopoverTrigger } from "cennge-ui";

const allLabels = ["bug", "documentation", "enhancement", "good first issue", "help wanted"];

export default function EditLabels() {
  const [isOpen, setOpen] = useState(false);
  const [labels, setLabels] = useState(["bug"]);
  const [draft, setDraft] = useState(labels);

  function apply(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setLabels(draft);
    setOpen(false);
  }

  return (
    <div className="flex flex-wrap items-center gap-2">
      <span className="text-sm font-medium">#52 Cached results outlive a model update</span>
      {labels.map((label) => (
        <Badge key={label} size="sm" variant="flat">
          {label}
        </Badge>
      ))}
      <Popover
        isOpen={isOpen}
        onOpenChange={(next) => {
          if (next) setDraft(labels);
          setOpen(next);
        }}
      >
        <PopoverTrigger>
          <Button size="sm" variant="light">
            Edit labels
          </Button>
        </PopoverTrigger>
        <PopoverContent align="start" aria-labelledby="labels-title" className="w-64">
          <form onSubmit={apply} className="flex flex-col gap-3">
            <p id="labels-title" className="font-semibold">
              Labels
            </p>
            {allLabels.map((label) => (
              <Checkbox
                key={label}
                isSelected={draft.includes(label)}
                onValueChange={(isSelected) => setDraft(isSelected ? [...draft, label] : draft.filter((item) => item !== label))}
              >
                {label}
              </Checkbox>
            ))}
            <Button type="submit" size="sm" color="primary" className="mt-1 self-end">
              Apply
            </Button>
          </form>
        </PopoverContent>
      </Popover>
    </div>
  );
}

Placement#

placement picks the side, align the edge it lines up with, and offset the gap to the button, 8px by default. With no room on its side the popover flips to the opposite one, and it slides along its side to stay 8px inside the screen. With no room on either side it runs past the edge, so keep left and right popovers narrow: these are w-48, and on a phone Left and Right flip.

placement.tsx
import { Button, Popover, PopoverContent, PopoverTrigger, type PopoverContentProps } from "cennge-ui";

const sides: { placement: NonNullable<PopoverContentProps["placement"]>; cell: string }[] = [
  { placement: "top", cell: "col-start-2" },
  { placement: "left", cell: "col-start-1 row-start-2" },
  { placement: "right", cell: "col-start-3 row-start-2" },
  { placement: "bottom", cell: "col-start-2 row-start-3" },
];

export default function PopoverPlacement() {
  return (
    <div className="mx-auto grid w-fit grid-cols-3 gap-2">
      {sides.map(({ placement, cell }) => (
        <Popover key={placement}>
          <PopoverTrigger>
            <Button variant="flat" className={cell}>
              {placement[0]!.toUpperCase() + placement.slice(1)}
            </Button>
          </PopoverTrigger>
          <PopoverContent placement={placement} showArrow aria-label="Last deploy" className="w-48">
            <p className="font-semibold">Last deploy</p>
            <p className="mt-1 text-muted">payload-cache went out from main and passed its checks.</p>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  );
}

Accessibility#

  • The button gets aria-haspopup="dialog", aria-expanded and, while open, aria-controls. The panel has role="dialog": name it with aria-label, or with aria-labelledby pointing at its title inside.
  • When it opens, focus moves to the first focusable element inside, and Tab and Shift+Tab cycle through what is inside. Escape returns focus to the button; a click outside closes the popover and leaves focus where the click put it.
  • The page around it is not blocked: it scrolls, and a click outside reaches what it was aimed at. With isModal the page takes no clicks and does not scroll until the popover closes, and screen readers see only the popover.
  • The end-to-end tests open a popover in Chromium, Firefox and WebKit, on desktop and phone profiles, close it with Escape and check focus on its button, then close it with a click outside. A popover opened inside a Dialog keeps its own list scrollable while the page behind stays put.

Styling#

className goes to the panel, which has p-4, rounded-large, a border and bg-surface-3; give it a width such as w-80, since it is only as wide as its content.

Props#

Popover#

PropTypeDefault
isOpen
boolean
–
defaultOpen
boolean
–
onOpenChange
(isOpen: boolean) => void
–
isModal
booleanTrap focus and block the page while open.
false
children
React.ReactNode
–

PopoverContent#

PropTypeDefault
placement
"left""right""top""bottom"
"bottom"
align
"start""center""end"
"center"
offset
numberGap from the trigger, in px.
8
showArrow
boolean
false
container
HTMLElement | null
–

Also takes every attribute of <div>.