Popover

A small panel anchored to a control for a closer look or a quick change: a run's budget, a model's settings, who can see a thread.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/popover

Once per app, add the registry to components.json:

components.json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/popover.json

Adds components/pixilui/popover.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.

Preview#

Side
Align
Arrow
State
Theme
Accent

The panel grows out of the exact corner of its trigger on a snappy spring, wherever Base UI places it.

Code#

components/pixilui/popover.tsx
"use client";

import {
  forwardRef,
  isValidElement,
  useCallback,
  useId,
  useState,
  type ComponentPropsWithoutRef,
  type ReactElement,
  type ReactNode,
} from "react";
import { Popover as BasePopover } from "@base-ui/react/popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { motionTokens, reducedTransition } from "./motion-tokens";
import { Button, type ButtonProps } from "./button";

export type PopoverSide = "top" | "bottom" | "left" | "right";
export type PopoverAlign = "start" | "center" | "end";

export type PopoverProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  /** What the panel holds: PopoverTitle, PopoverDescription, controls, PopoverClose. */
  children?: ReactNode;
  /** The element the panel anchors to and opens from, usually a Button. Rendered through Base UI's render prop. */
  trigger: ReactElement;
  /** Controlled open state. Pair with onOpenChange. */
  open?: boolean;
  /** Starting open state when uncontrolled. */
  defaultOpen?: boolean;
  /** Runs whenever the popover opens or closes. */
  onOpenChange?: (open: boolean) => void;
  /** Preferred side of the trigger. It flips when there is no room. */
  side?: PopoverSide;
  /** Alignment along that side. */
  align?: PopoverAlign;
  /** Gap between the trigger and the panel, in px. */
  offset?: number;
  /** Draw a small arrow pointing at the trigger. */
  arrow?: boolean;
  /** Also open when the trigger is hovered (fine pointers only). Clicks still toggle it. */
  hoverOpen?: boolean;
  /** Where the popover portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */
  container?: HTMLElement | null;
};

/** Keeps the arrow clear of the panel's rounded corners. */
const ARROW_PADDING = 22;

function PopoverArrow() {
  return (
    <BasePopover.Arrow
      data-slot="popover-arrow"
      className={cn(
        "flex",
        "data-[side=bottom]:-top-2 data-[side=top]:-bottom-2 data-[side=top]:rotate-180",
        "data-[side=left]:-right-3 data-[side=left]:rotate-90 data-[side=right]:-left-3 data-[side=right]:-rotate-90",
      )}
    >
      <svg width="20" height="10" viewBox="0 0 20 10" aria-hidden="true">
        <path d="M0 10C4 10 6.5 2 10 2s6 8 10 8" className="fill-surface-raised" />
        <path d="M0 10C4 10 6.5 2 10 2s6 8 10 8" fill="none" className="stroke-border" strokeWidth="1" />
      </svg>
    </BasePopover.Arrow>
  );
}

export const Popover = forwardRef<HTMLDivElement, PopoverProps>(function Popover(
  {
    children,
    trigger,
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    side = "bottom",
    align = "center",
    offset = 8,
    arrow = false,
    hoverOpen = false,
    container,
    className,
    ...rest
  },
  forwardedRef,
) {
  const reduced = useReducedMotion() ?? false;
  const [innerOpen, setInnerOpen] = useState(defaultOpen);
  const open = openProp ?? innerOpen;
  const generatedTriggerId = useId();
  const triggerId = (isValidElement<{ id?: string }>(trigger) && trigger.props.id) || generatedTriggerId;

  const setOpen = useCallback(
    (next: boolean) => {
      setInnerOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );

  const panel = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
    : {
        initial: { opacity: 0, scale: motionTokens.scale.enterFrom },
        animate: { opacity: 1, scale: 1 },
        exit: {
          opacity: 0,
          scale: motionTokens.scale.enterFrom,
          transition: { duration: motionTokens.duration.quick, ease: motionTokens.ease.exit },
        },
        transition: {
          scale: motionTokens.spring.snappy,
          opacity: { duration: motionTokens.duration.quick, ease: motionTokens.ease.enter },
        },
      };

  const nativeButton = typeof trigger.type === "string" ? trigger.type === "button" : true;

  return (
    <BasePopover.Root open={open} triggerId={triggerId} onOpenChange={(next) => setOpen(next)}>
      <BasePopover.Trigger id={triggerId} nativeButton={nativeButton} openOnHover={hoverOpen} render={trigger} />
      <AnimatePresence>
        {open ? (
          <BasePopover.Portal keepMounted container={container}>
            <BasePopover.Positioner side={side} align={align} sideOffset={offset} arrowPadding={ARROW_PADDING} className="z-50 outline-none">
              <BasePopover.Popup
                ref={forwardedRef}
                data-slot="popover"
                data-state={open ? "open" : "closed"}
                className={cn(
                  "w-max max-w-[min(var(--available-width),22rem)] origin-(--transform-origin) rounded-panel bg-surface-raised p-4 font-body text-14 text-foreground shadow-floating outline-none hairline",
                  className,
                )}
                render={<motion.div {...panel} />}
                {...rest}
              >
                {arrow ? <PopoverArrow /> : null}
                {children}
              </BasePopover.Popup>
            </BasePopover.Positioner>
          </BasePopover.Portal>
        ) : null}
      </AnimatePresence>
    </BasePopover.Root>
  );
});

export type PopoverTitleProps = ComponentPropsWithoutRef<"h2">;

/** Names the popover; Base UI wires it to aria-labelledby. */
export const PopoverTitle = forwardRef<HTMLHeadingElement, PopoverTitleProps>(function PopoverTitle({ className, ...rest }, ref) {
  return <BasePopover.Title ref={ref} className={cn("text-14 font-medium text-foreground", className)} {...rest} />;
});

export type PopoverDescriptionProps = ComponentPropsWithoutRef<"p">;

/** Supporting sentence; Base UI wires it to aria-describedby. */
export const PopoverDescription = forwardRef<HTMLParagraphElement, PopoverDescriptionProps>(function PopoverDescription({ className, ...rest }, ref) {
  return <BasePopover.Description ref={ref} className={cn("mt-1 text-pretty text-14 text-secondary", className)} {...rest} />;
});

export type PopoverCloseProps = ButtonProps;

/** A Button that closes the popover and returns focus to the trigger. Secondary and small by default. */
export const PopoverClose = forwardRef<HTMLButtonElement, PopoverCloseProps>(function PopoverClose(
  { variant = "secondary", size = "sm", icon, iconEnd, loading, ...rest },
  ref,
) {
  return (
    <BasePopover.Close
      ref={ref}
      render={(props) => <Button {...(props as ButtonProps)} variant={variant} size={size} icon={icon} iconEnd={iconEnd} loading={loading} />}
      {...(rest as ComponentPropsWithoutRef<"button">)}
    />
  );
});

Usage#

Use it when

  • Details or a couple of controls that belong to one button: a budget, filters, sharing.
  • Context the person checks and dismisses without leaving the page.

Use something else when

  • A text label for an icon button: use a tooltip.
  • A list of commands: use a menu.
  • A decision that has to be made before going on: use a dialog.
Example
import { useState } from "react";
import { Gauge } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { Popover, PopoverClose, PopoverDescription, PopoverTitle } from "@/components/pixilui/popover";

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  side: ["bottom", "top", "right", "left"],
  align: ["center", "start", "end"],
  arrow: ["off", "on"],
  state: ["open", "closed"],
} as const;

const rows = [
  { label: "Spent", value: "$3.42" },
  { label: "Limit", value: "$10.00" },
  { label: "Tokens", value: "41,280" },
];

export default function PopoverDemo({ side = "bottom", align = "center", arrow = "off", state = "open" }: KnobValues<typeof knobs>) {
  // The popover portals into this stage, so it keeps the preview's theme and accent.
  const [stage, setStage] = useState<HTMLDivElement | null>(null);

  return (
    <div ref={setStage} className="relative grid h-[440px] w-full max-w-2xl place-items-center rounded-surface bg-surface hairline">
      <Popover
        key={`${side}-${align}-${arrow}-${state}`}
        container={stage}
        defaultOpen={state === "open"}
        side={side}
        align={align}
        arrow={arrow === "on"}
        trigger={
          <Button variant="secondary" size="sm" icon={<Gauge strokeWidth={1.5} />}>
            Run budget
          </Button>
        }
      >
        <PopoverTitle>Budget for run 4821</PopoverTitle>
        <PopoverDescription>The agent pauses and asks you before it spends past the limit.</PopoverDescription>
        <dl className="mt-3 flex flex-col">
          {rows.map((row) => (
            <div key={row.label} className="flex items-baseline justify-between gap-6 border-t border-border-subtle py-2">
              <dt className="text-secondary">{row.label}</dt>
              <dd className="numeric text-foreground">{row.value}</dd>
            </div>
          ))}
        </dl>
        <div className="mt-2 flex justify-end">
          <PopoverClose variant="primary">Raise to $20</PopoverClose>
        </div>
      </Popover>
    </div>
  );
}

API#

PropTypeDefaultDescription
childrenReactNodeWhat the panel holds: PopoverTitle, PopoverDescription, controls, PopoverClose.
triggerrequiredReactElementThe element the panel anchors to and opens from, usually a Button. Rendered through Base UI's render prop.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseStarting open state when uncontrolled.
onOpenChange(open: boolean) => voidRuns whenever the popover opens or closes.
side"top" | "bottom" | "left" | "right""bottom"Preferred side of the trigger. It flips when there is no room.
align"start" | "center" | "end""center"Alignment along that side.
offsetnumber8Gap between the trigger and the panel, in px.
arrowbooleanfalseDraw a small arrow pointing at the trigger.
hoverOpenbooleanfalseAlso open when the trigger is hovered (fine pointers only). Clicks still toggle it.
containerHTMLElement | nullWhere the popover portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it.

Also takes every native <div> attribute, and a ref to it.

Keyboard#

Enter
On the trigger, toggles the popover; Space does the same.
Tab
Moves through the controls inside; tabbing past the last one closes it and moves on.
Esc
Closes the popover and returns focus to the trigger.

Accessibility#

  • Base UI renders the panel as a dialog labelled by PopoverTitle and described by PopoverDescription, and sets aria-expanded and aria-controls on the trigger.
  • Focus moves into the panel on open and back to the trigger on close.
  • It is not modal: the page stays scrollable and usable, and an outside press closes it.
  • hoverOpen is off by default because hover alone never reaches touch or keyboard users; clicking still works when it is on.

Motion#

  • The panel scales up from 0.96 on the snappy spring, growing out of the trigger: transform-origin follows the side and alignment Base UI resolved.
  • It closes in 150ms on the exit ease.
  • Reduced motion: fade only.

Responsive#

  • The panel is as wide as its content up to 352px and never wider than the space beside the trigger.
  • It flips to the other side when there is no room and shifts to stay on screen.

Performance#

  • Nothing is mounted while closed; positioning updates only while open.

Notes for AI#

Also in the markdown page and llms.txt
  1. Pass the anchor as trigger; the popover opens from it and returns focus to it.
  2. Start content with PopoverTitle so the panel has an accessible name.
  3. Use PopoverClose for the action that finishes the job ("Raise to $20"); it closes and restores focus.
  4. Pass side and align for placement; it still flips when it would leave the screen.