# 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.

Tier: free. Type: component. Category: overlays. Tags: keyboard, motion.

## Install

```bash
npx shadcn@latest add @pixilui/popover
```

One-time setup: add the registry to `components.json`:

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

Files: `components/pixilui/popover.tsx`. npm: `@base-ui/react`, `motion`.

## Preview

Live preview: https://pixilui.com/components/popover

Knobs:

- `side`: `bottom`, `top`, `right`, `left`
- `align`: `center`, `start`, `end`
- `arrow`: `off`, `on`
- `state`: `open`, `closed`

Signature: 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`

```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

When to use:

- 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.

When not to use:

- 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.

```tsx
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>
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` |  | What the panel holds: PopoverTitle, PopoverDescription, controls, PopoverClose. |
| `trigger` (required) | `ReactElement` |  | The element the panel anchors to and opens from, usually a Button. Rendered through Base UI's render prop. |
| `open` | `boolean` |  | Controlled open state. Pair with onOpenChange. |
| `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` |  | Runs 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. |
| `offset` | `number` | `8` | Gap between the trigger and the panel, in px. |
| `arrow` | `boolean` | `false` | Draw a small arrow pointing at the trigger. |
| `hoverOpen` | `boolean` | `false` | Also open when the trigger is hovered (fine pointers only). Clicks still toggle it. |
| `container` | `HTMLElement \| null` |  | 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. |

Also accepts 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

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

## Related

- [Tooltip](https://pixilui.com/components/tooltip/markdown): A short text label for an icon button, with its keyboard shortcut, that waits 400ms and then opens neighbours instantly while you scan a toolbar.
- [Dialog](https://pixilui.com/components/dialog/markdown): A modal panel for one decision or one short task: a title, what happens, and the buttons that do it, with pending and error handled.
- [Drawer](https://pixilui.com/components/drawer/markdown): A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel.
- [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place.
