Skip to content
Seamless UI

Popover

Save

A floating panel anchored to a trigger, built on Base UI Popover, that grows from the trigger and has an optional hairline arrow.

Category
Core
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Popover preview

Installation

npx @seamlessui/cli add popover

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add popover --dry-run

Usage

popover-example.tsx
import { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/seamless/ui/popover";

// Same recipe as the Button component's secondary variant, inlined so this example has no other dependency.
const button =
  "inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-zinc-950/12 bg-white px-3.5 text-sm font-medium text-zinc-950 select-none " +
  "transition-[background-color,border-color,scale] duration-150 ease-out active:duration-100 motion-safe:active:scale-[0.97] motion-reduce:transition-none " +
  "hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] data-[popup-open]:bg-zinc-950/[0.04] " +
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 " +
  "dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06] dark:data-[popup-open]:bg-white/[0.06]";

export default function PopoverExample() {
  return (
    <Popover>
      <PopoverTrigger className={button}>Share</PopoverTrigger>
      <PopoverContent arrow className="grid w-80 gap-3">
        <div className="grid gap-0.5">
          <PopoverTitle>Share this page</PopoverTitle>
          <PopoverDescription>Anyone with the link can view it.</PopoverDescription>
        </div>
        <input
          readOnly
          aria-label="Page link"
          value="https://acme.dev/docs/limits"
          className={
            "h-8 rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-2.5 font-mono text-xs text-zinc-700 " +
            "focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none " +
            "dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300"
          }
        />
        <PopoverClose className={`${button} h-8 justify-self-end px-3`}>Done</PopoverClose>
      </PopoverContent>
    </Popover>
  );
}

API

PropTypeDefaultDescription
Popover open / defaultOpen / onOpenChangeboolean / boolean / (open, details) => void—Root state. Leave open unset for an uncontrolled popover.
Popover modalboolean | "trap-focus"falseTraps focus and blocks the page while open. Off by default.
PopoverTriggerBase UI Popover.Trigger props—Unstyled button that toggles the popover. Pass className, or render={<Button />}. openOnHover is supported.
PopoverContent side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"Preferred side of the trigger. Flips when there is not enough room.
PopoverContent align"start" | "center" | "end""center"Alignment against the trigger.
PopoverContent sideOffset / alignOffsetnumber8 / 0Gap from the trigger and shift along its edge, in pixels.
PopoverContent arrowbooleanfalseShows a 16 by 9 notch drawn in the panel fill and border color, pointing at the trigger. Adds 2px to sideOffset so the tip clears the trigger.
PopoverContent positionerClassNamestring—Classes for the positioner, for example a different z-index.
PopoverTitle / PopoverDescriptionBase UI Popover.Title / Popover.Description props—Optional heading and text, wired to aria-labelledby and aria-describedby.
PopoverCloseBase UI Popover.Close props—Unstyled button that closes the popover.

Accessibility

  • The trigger sets aria-expanded and aria-controls; the panel has role="dialog", named by PopoverTitle when present.
  • Opening moves focus into the panel and closing returns it to the trigger.
  • Escape and a click outside close the popover.
  • The arrow is decorative and hidden from assistive technology.
  • The panel scales from the trigger side (origin-aware) in 160ms and fades out in 110ms; with reduced motion only the opacity changes, and transitions are removed entirely.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/popover.tsxpopover
  • npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/popover/0.2.0.json.

ui/popover.tsxShow
ui/popover.tsx
"use client";

import { Popover as BasePopover } from "@base-ui/react/popover";

type ClassName<State> = string | ((state: State) => string | undefined) | undefined;

function cx(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(" ");
}

/** Prepends base classes while keeping Base UI's state-function form of className working. */
function withBase<State>(base: string, className: ClassName<State>) {
  return typeof className === "function" ? (state: State) => cx(base, className(state)) : cx(base, className);
}

/** Groups the popover parts and holds its open state. Pass `open`/`onOpenChange` to control it. */
export const Popover = BasePopover.Root;

/** A button that toggles the popover. Unstyled; pass `className`, or `render={<Button />}` to reuse your button. */
export const PopoverTrigger = BasePopover.Trigger;

/** A button that closes the popover. Unstyled; pass `className` or `render`. */
export const PopoverClose = BasePopover.Close;

type PositionerProps = BasePopover.Positioner.Props;

export interface PopoverContentProps extends BasePopover.Popup.Props {
  /** Side of the trigger to place the popover on. Flips when there is not enough room. */
  side?: PositionerProps["side"];
  /** Alignment against the trigger on the chosen side. */
  align?: PositionerProps["align"];
  /** Gap between the trigger and the popover, in pixels. */
  sideOffset?: PositionerProps["sideOffset"];
  /** Shift along the trigger edge, in pixels. */
  alignOffset?: PositionerProps["alignOffset"];
  /** Shows an arrow pointing at the trigger. */
  arrow?: boolean;
  /** Classes for the positioner wrapper, for example to change the z-index. */
  positionerClassName?: string;
  /** Element to portal into. Defaults to `document.body`. */
  container?: BasePopover.Portal.Props["container"];
}

/** The floating panel, rendered in a portal and positioned against the trigger. */
export function PopoverContent({
  side = "bottom",
  align = "center",
  sideOffset = 8,
  alignOffset = 0,
  arrow = false,
  positionerClassName,
  container,
  className,
  children,
  ...props
}: PopoverContentProps) {
  return (
    <BasePopover.Portal container={container}>
      <BasePopover.Positioner
        side={side}
        align={align}
        sideOffset={arrow && typeof sideOffset === "number" ? sideOffset + 2 : sideOffset}
        alignOffset={alignOffset}
        className={cx("z-50", positionerClassName)}
      >
        <BasePopover.Popup
          className={withBase(
            cx(
              "relative w-72 max-w-(--available-width) origin-(--transform-origin) rounded-xl p-4 text-sm",
              "border border-zinc-950/10 bg-white text-zinc-950 shadow-[0_12px_32px_-8px_rgb(0_0_0/0.18)]",
              "dark:border-white/15 dark:bg-black dark:text-white dark:shadow-[0_16px_48px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)]",
              "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
              // Enter: 160ms from 96% at the trigger. Exit: 110ms, fades with a smaller scale.
              "transition-[opacity,scale] duration-160 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
              "data-[starting-style]:opacity-0 motion-safe:data-[starting-style]:scale-96",
              "data-[ending-style]:opacity-0 data-[ending-style]:duration-110 data-[ending-style]:ease-out motion-safe:data-[ending-style]:scale-98",
            ),
            className,
          )}
          {...props}
        >
          {arrow ? <PopoverArrow /> : null}
          {children}
        </BasePopover.Popup>
      </BasePopover.Positioner>
    </BasePopover.Portal>
  );
}

/** A 16 x 9 notch drawn in the panel's own fill and hairline, so it reads as part of the border. */
function PopoverArrow() {
  return (
    <BasePopover.Arrow
      className={cx(
        "flex",
        "data-[side=bottom]:-top-[9px]",
        "data-[side=top]:-bottom-[9px] data-[side=top]:rotate-180",
        "data-[side=left]:-right-[12.5px] data-[side=left]:rotate-90",
        "data-[side=right]:-left-[12.5px] data-[side=right]:-rotate-90",
      )}
    >
      <svg aria-hidden="true" width="16" height="9" viewBox="0 0 16 9" fill="none">
        <path d="M0 9.5L8 1.5L16 9.5Z" className="fill-white dark:fill-black" />
        <path d="M0 9L8 1L16 9" strokeWidth="1" className="stroke-zinc-950/10 dark:stroke-white/15" />
      </svg>
    </BasePopover.Arrow>
  );
}

/** Optional heading. Labels the popover with aria-labelledby. */
export function PopoverTitle({ className, ...props }: BasePopover.Title.Props) {
  return <BasePopover.Title className={withBase("text-sm leading-5 font-semibold", className)} {...props} />;
}

/** Optional supporting text. Describes the popover with aria-describedby. */
export function PopoverDescription({ className, ...props }: BasePopover.Description.Props) {
  return <BasePopover.Description className={withBase("text-sm leading-5 text-zinc-600 dark:text-zinc-400", className)} {...props} />;
}