Popover

Rich floating content anchored to a trigger.

Installation

Terminal
pnpm dlx @dowel-ui/cli add popover

npm packages installed: radix-ui.

Accessibility

The content has role="dialog" and therefore REQUIRES an accessible name — pass aria-label, or aria-labelledby pointing at a heading inside it. A development-only warning fires if neither is present. The trigger exposes aria-expanded and aria-controls; Escape closes and returns focus to it. Non-modal by default, so the rest of the page stays reachable; pass `modal` only when the content is a task the user must finish or abandon.

Props

PopoverContent

Every prop of Radix UI’s Popover.Content, plus className.

PopoverArrow

Every prop of Radix UI’s Popover.Arrow, plus className.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add popover writes into your project, with imports rewritten to your own path alias.

ui/popover.tsx
"use client";

import { Popover as PopoverPrimitive } from "radix-ui";
import { useEffect, type ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils";

/**
 * Rich floating content anchored to a trigger.
 *
 * Non-modal by default: the page stays interactive and focus is not trapped,
 * which is right for a surface the user can simply click away from. Pass
 * `modal` when the popover contains a task that must be completed or abandoned.
 *
 * The content carries `role="dialog"`, so it MUST have an accessible name via
 * `aria-label` or `aria-labelledby`. Without one it is announced as an unnamed
 * dialog, which tells a screen reader user nothing about what just opened.
 */
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverAnchor = PopoverPrimitive.Anchor;
export const PopoverClose = PopoverPrimitive.Close;

export type PopoverContentProps = ComponentPropsWithRef<typeof PopoverPrimitive.Content>;

export function PopoverContent({
  className,
  align = "center",
  sideOffset = 8,
  ...props
}: PopoverContentProps) {
  useMissingLabelWarning(props["aria-label"], props["aria-labelledby"]);

  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Content
        data-slot="popover-content"
        align={align}
        sideOffset={sideOffset}
        className={cn(
          "z-[var(--z-popover)] w-72 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-lg",
          "max-h-[var(--radix-popover-content-available-height)] outline-none",
          // One keyframe pair covers every side and alignment: the primitive
          // computes the origin, so the panel always grows from its anchor.
          "origin-[var(--radix-popover-content-transform-origin)]",
          "data-[state=closed]:animate-float-out data-[state=open]:animate-float-in",
          className,
        )}
        {...props}
      />
    </PopoverPrimitive.Portal>
  );
}

/**
 * Warns, in development only, when the content has no accessible name.
 *
 * This is a mistake that is invisible in the browser and serious for screen
 * reader users, so it is surfaced at the moment it is introduced rather than
 * left for an audit. Typed as optional rather than required because a name can
 * legitimately come from either attribute, and forcing one at the type level
 * would break composition patterns that supply it dynamically.
 */
function useMissingLabelWarning(label?: string, labelledBy?: string) {
  useEffect(() => {
    if (process.env.NODE_ENV === "production") return;
    if (label ?? labelledBy) return;

    console.warn(
      '[PopoverContent] Missing accessible name. The content has role="dialog", so pass ' +
        "aria-label, or aria-labelledby pointing at a heading inside it.",
    );
  }, [label, labelledBy]);
}

export function PopoverArrow({
  className,
  ...props
}: ComponentPropsWithRef<typeof PopoverPrimitive.Arrow>) {
  return (
    <PopoverPrimitive.Arrow
      data-slot="popover-arrow"
      width={12}
      height={6}
      className={cn("fill-popover stroke-border", className)}
      {...props}
    />
  );
}