docs / core / popover

Popover

a small panel you click open, anchored to whatever you clicked. the page stays live behind it, so it is for the extras you reach for on purpose: a filter set, a menu of actions, a compact form.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

small anchored panels: notifications, filters, a compact formTitle + Description inside Content give it an accessible namenot modal. anything that must block the page is a Dialognot a tooltip. plain hover labels stay Tooltip

a11y

opens on click, closes on click-outside and Escape · Title and Description wire the popup's name and descriptionjest-axe

dependencies

@base-ui/react
live demo · try it out
customize
sidepreferred side of the trigger
alignalignment along that side
sideOffsetgap from the trigger, in pixels
8
titlethe popup's accessible name
descriptionsupporting line below the title
usagecopy
import { Popover } from "usva/primitives/popover";

<Popover>
  <Popover.Trigger>Open</Popover.Trigger>
  <Popover.Content>
    <Popover.Title>Notifications</Popover.Title>
    <Popover.Description>You have no new notifications.</Popover.Description>
  </Popover.Content>
</Popover>

props

proptypedefaultnotes
openbooleancontrolled open state.
defaultOpenbooleanfalseinitial open state when uncontrolled.
onOpenChange(open, eventDetails) => voidfires with the new open state first.
side"top" | "right" | "bottom" | "left"preferred side of the trigger, on Content.
align"start" | "center" | "end"alignment along the chosen side, on Content.
sideOffsetnumber8distance in pixels from the trigger, on Content.

get it

npx shadcn add https://usva.build/r/popover.jsoncopy
source · components/ui/popover.tsxexactly what this command copiescopy
"use client";
import { Popover as Base } from "@base-ui/react/popover";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useScopedTheme } from "./use-scoped-theme";

export type PopoverProps = Base.Root.Props;

function PopoverRoot(props: PopoverProps) {
  return <Base.Root {...props} />;
}
PopoverRoot.displayName = "Popover";

export const PopoverTrigger = Base.Trigger;

export type PopoverContentProps = React.ComponentPropsWithoutRef<
  typeof Base.Popup
> & {
  side?: Base.Positioner.Props["side"];
  align?: Base.Positioner.Props["align"];
  sideOffset?: number;
};

export const PopoverContent = React.forwardRef<
  HTMLDivElement,
  PopoverContentProps
>(({ className, side, align, sideOffset = 8, children, ...props }, ref) => {
  const [probe, theme] = useScopedTheme();

  return (
    <>
      <span ref={probe} hidden />
      <Base.Portal>
        <Base.Positioner
          data-theme={theme}
          side={side}
          align={align}
          sideOffset={sideOffset}
          className="z-overlay"
        >
          <Base.Popup
            ref={ref}
            className={cn(
              "rim-light rounded-xl border border-border bg-surface-2 p-4 text-ink shadow-floating outline-none",
              "origin-[var(--transform-origin)] transition-enter duration-base ease-spring motion-reduce:transition-none motion-reduce:transform-none",
              "data-[starting-style]:translate-y-1 data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0 data-[starting-style]:blur-[2px]",
              "data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[ending-style]:duration-fast data-[ending-style]:ease-soft",
              className,
            )}
            {...props}
          >
            {children}
          </Base.Popup>
        </Base.Positioner>
      </Base.Portal>
    </>
  );
});
PopoverContent.displayName = "PopoverContent";

export const PopoverTitle = React.forwardRef<
  HTMLHeadingElement,
  React.ComponentPropsWithoutRef<typeof Base.Title>
>(({ className, ...props }, ref) => (
  <Base.Title
    ref={ref}
    className={cn("text-sm font-semibold text-ink", className)}
    {...props}
  />
));
PopoverTitle.displayName = "PopoverTitle";

export const PopoverDescription = React.forwardRef<
  HTMLParagraphElement,
  React.ComponentPropsWithoutRef<typeof Base.Description>
>(({ className, ...props }, ref) => (
  <Base.Description
    ref={ref}
    className={cn("text-sm text-muted", className)}
    {...props}
  />
));
PopoverDescription.displayName = "PopoverDescription";

export const PopoverClose = React.forwardRef<
  HTMLButtonElement,
  React.ComponentPropsWithoutRef<typeof Base.Close>
>(({ className, ...props }, ref) => (
  <Base.Close
    ref={ref}
    className={cn(
      "inline-flex items-center justify-center rounded-md text-muted outline-none",
      "transition-tint duration-fast ease-soft",
      "hover:text-ink focus-visible:ring-focus",
      className,
    )}
    {...props}
  />
));
PopoverClose.displayName = "PopoverClose";

export const Popover = Object.assign(PopoverRoot, {
  Trigger: PopoverTrigger,
  Content: PopoverContent,
  Title: PopoverTitle,
  Description: PopoverDescription,
  Close: PopoverClose,
});