# Popover

A shadcn-based popover with clear composition and smooth Motion transitions.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, PopoverClose } from "@/components/ui/popover";

export const PopoverDemo = () => (
  <Popover defaultOpen={false}>
    <PopoverTrigger disabled={false} render={<Button variant="outline" />}>Manage access</PopoverTrigger>
    <PopoverContent side="bottom" align="start" sideOffset={6} animated={true}>
      <PopoverHeader>
        <PopoverTitle>{"Workspace access"}</PopoverTitle>
        <PopoverDescription>Only invited members can view this workspace.</PopoverDescription>
      </PopoverHeader>
      <PopoverClose render={<Button variant="secondary" size="sm" />}>Done</PopoverClose>
    </PopoverContent>
  </Popover>
);
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/popover.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react@^1.8.0 cn motion@^12.38.0
```

## Usage [#usage]

```tsx
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
  PopoverHeader,
  PopoverTitle,
  PopoverDescription,
} from "@/components/ui/popover";

<Popover>
  <PopoverTrigger>Open details</PopoverTrigger>
  <PopoverContent>
    <PopoverHeader>
      <PopoverTitle>Workspace access</PopoverTitle>
      <PopoverDescription>
        Only invited members can view this workspace.
      </PopoverDescription>
    </PopoverHeader>
  </PopoverContent>
</Popover>;
```

The compound API keeps arbitrary content explicit. `PopoverContent` includes its portal and positioner. `PopoverHeader` handles spacing; `PopoverTitle` and `PopoverDescription` provide the accessible name and description. Use `PopoverClose` for an explicit exit.

Compose a Vandor Button with `PopoverTrigger render={<Button variant="outline" />}` or `PopoverClose render={<Button />}`. This is the Base UI `render` API, not Radix `asChild`. Install [Button](/docs/components/button) separately when using these examples.

## Examples [#examples]

Use the compound API to choose placement and keep arbitrary content inside the positioned popup.

<ComponentPreview name="popover-demo" title="popover-demo.tsx">
  <PopoverDemo />
</ComponentPreview>

### Controlled visibility [#controlled-visibility]

Use `open` and `onOpenChange` when the surrounding feature owns visibility. The callback receives the requested state and Base UI event details; call `details.cancel()` to reject a request. Dismissal does not commit or revert application data automatically.

<ComponentPreview name="popover-controlled-demo" title="popover-controlled-demo.tsx">
  <PopoverControlledDemo />
</ComponentPreview>

### Placement and motion [#placement-and-motion]

Content defaults to bottom/start with a 6px offset, preserving existing Vandor usage. Use `side`, `align`, `sideOffset`, and `alignOffset` to adjust placement. Base UI flips or shifts near viewport edges; the popup uses the actual placement's transform origin. Available-height scrolling keeps long content reachable.

Motion replaces shadcn's CSS entrance/exit animations with an 180ms reveal and 120ms dismissal, using subtle scale and opacity with no bounce. Set `animated={false}` for immediate transitions. Reduced-motion preferences also disable transitions. Do not add CSS transform or animation utilities to the same popup, since they can compete with Motion.

### Consumer setup and optional stories [#consumer-setup-and-optional-stories]

Requires React 19, Tailwind CSS 4, Base UI 1.8+, Motion 12.38+, and `cn`. Use shadcn semantic theme tokens, including `popover`, `popover-foreground`, `border`, and `muted-foreground`. No website-only animation CSS, sound, or provider is required. The registry's `new-york` directory name does not imply a Radix foundation; this component adapts shadcn Base Nova.

Install Popover first. Portable stories are a separate optional item and do not reinstall or overwrite the component:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/popover-stories.json
```

Use your existing `@storybook/react` setup with matching Storybook package versions (types/composition checked with 10.6.1). Discover `**/*.stories.tsx` in your configured UI directory and load your global Tailwind/theme stylesheet in the preview. No extra addons or providers are required for Canvas and Controls; the `autodocs` tag uses your existing `@storybook/addon-docs` setup. Stories inherit your consumer theme. This repository has no Storybook server; Storybook UI appearance is not verified here.

Popover and its stories now follow the UI alias in your `components.json`. On the default layout this remains `components/ui/popover.tsx`. If you previously installed the fixed-target version with a custom alias, migrate your existing Popover and imports to that UI directory before adding stories. Installing the component can replace a customized Popover at its destination; review the CLI diff before accepting an overwrite. The stories-only item does not replace it.

## Behavior & Accessibility [#behavior--accessibility]

The trigger retains button semantics, refs, and consumer event handlers. Escape and outside interaction use Base UI dismissal; default focus placement and restoration follow the primitive, including touch-device behavior. `initialFocus` and `finalFocus` override these defaults on Content. Title and Description connect to the popup automatically.

The default is non-modal. If using `modal={true}` or `modal="trap-focus"`, include a reachable `PopoverClose`, including for touch screen-reader users. Disabled triggers prevent trigger interaction, not externally controlled opening. Use Dialog instead for substantial modal workflows, and Tooltip for short non-interactive hints.

Root inherits [Base UI Root props](https://base-ui.com/react/components/popover); Trigger, Close, Title, and Description inherit their corresponding primitive props. Content forwards Popup props and the positioning options listed below, not every Positioner prop. Header forwards native div props. Consumer refs and custom `render` elements are retained during motion composition.

## Props [#props]

| Prop                 | Type                                                                     | Default         | Description                                                                                                      |
| -------------------- | ------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------- |
| actionsRef           | RefObject\<Popover.Root.Actions \| null>                                 | Not set         | Popover: imperative close/unmount actions. The component manages animation unmounting.                           |
| align                | "start" \| "center" \| "end"                                             | "start"         | PopoverContent: alignment relative to the trigger.                                                               |
| alignOffset          | number                                                                   | 0               | PopoverContent: offset along the alignment axis.                                                                 |
| anchor               | Positioner.Props\['anchor']                                              | Trigger         | PopoverContent: optional alternate positioning anchor.                                                           |
| animated             | boolean                                                                  | true            | PopoverContent: Motion entrance and exit; reduced motion disables transitions.                                   |
| collisionAvoidance   | Positioner.Props\['collisionAvoidance']                                  | Base UI default | PopoverContent: inherited flip/shift placement policy.                                                           |
| collisionPadding     | number \| Partial\<Record\<Side, number>>                                | 8               | PopoverContent: padding from collision boundaries.                                                               |
| defaultOpen          | boolean                                                                  | false           | Popover: initial uncontrolled state. Changing this control remounts the preview.                                 |
| disabled             | boolean                                                                  | false           | PopoverTrigger: prevents trigger interaction, not programmatic opening.                                          |
| finalFocus           | Popup.Props\['finalFocus']                                               | Base UI default | PopoverContent: controls focus restoration when closed.                                                          |
| initialFocus         | Popup.Props\['initialFocus']                                             | Base UI default | PopoverContent: controls focus when opened; touch behavior follows Base UI.                                      |
| modal                | boolean \| "trap-focus"                                                  | false           | Popover: modal interaction policy. Include PopoverClose for modal accessibility.                                 |
| onOpenChange         | (open, details) => void                                                  | Not set         | Popover: receives requested state and Base UI reason. details.cancel() rejects the change.                       |
| onOpenChangeComplete | (open: boolean) => void                                                  | Not set         | Popover: notified after the opening or closing lifecycle completes.                                              |
| open                 | boolean                                                                  | Not set         | Popover: controlled open state; pair with onOpenChange.                                                          |
| render               | ReactElement \| ((props, state) => ReactElement)                         | Not set         | Trigger, Close, Title, Description, Content: compose a custom element while preserving primitive props and refs. |
| side                 | "top" \| "bottom" \| "left" \| "right" \| "inline-start" \| "inline-end" | "bottom"        | PopoverContent: preferred side; collision avoidance can flip it.                                                 |
| sideOffset           | number                                                                   | 6               | PopoverContent: distance from the trigger in pixels.                                                             |
| title                | ReactNode                                                                | Not set         | PopoverTitle: accessible popup heading, composed inside PopoverHeader.                                           |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { mergeProps } from "@base-ui/react/merge-props";
import { Popover as Primitive } from "@base-ui/react/popover";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useRef } from "react";
import type { ComponentProps } from "react";

const ActionsContext = createContext<(() => void) | null>(null);

export const Popover = <Payload,>({
  actionsRef,
  onOpenChange,
  ...props
}: Primitive.Root.Props<Payload>) => {
  const internalActions = useRef<Primitive.Root.Actions | null>(null);
  const actions = actionsRef ?? internalActions;
  return (
    <ActionsContext.Provider value={() => actions.current?.unmount()}>
      <Primitive.Root
        {...props}
        actionsRef={actions}
        onOpenChange={(open, details) => {
          onOpenChange?.(open, details);
          if (!open && !details.isCanceled) {
            details.preventUnmountOnClose();
          }
        }}
      />
    </ActionsContext.Provider>
  );
};

export const PopoverTrigger = (
  props: ComponentProps<typeof Primitive.Trigger>
) => <Primitive.Trigger data-slot="popover-trigger" {...props} />;

export const PopoverClose = (props: ComponentProps<typeof Primitive.Close>) => (
  <Primitive.Close data-slot="popover-close" {...props} />
);

const PopupElement = ({
  nativeProps,
  render,
  state,
  ref,
  ...props
}: ComponentProps<"div"> & {
  nativeProps: ComponentProps<"div">;
  render?: Primitive.Popup.Props["render"];
  state: Primitive.Popup.State;
}) =>
  useRender({
    props: mergeProps(nativeProps, props),
    ref: [nativeProps.ref ?? null, ref ?? null],
    render,
    state: { ...state },
  });
const MotionPopupElement = motion.create(PopupElement);

const AnimatedPopup = ({
  nativeProps,
  render,
  state,
  animated,
}: {
  nativeProps: ComponentProps<"div">;
  render?: Primitive.Popup.Props["render"];
  state: Primitive.Popup.State;
  animated: boolean;
}) => {
  const reduceMotion = useReducedMotion();
  const unmount = useContext(ActionsContext);
  const openRef = useRef(state.open);
  openRef.current = state.open;
  const animate = animated && !reduceMotion;
  useEffect(() => {
    if (!state.open && !animate) {
      unmount?.();
    }
  }, [state.open, animate, unmount]);
  return (
    <MotionPopupElement
      nativeProps={nativeProps}
      render={render}
      state={state}
      initial={{ opacity: animate ? 0 : 1, scale: animate ? 0.97 : 1 }}
      animate={{
        opacity: state.open ? 1 : 0,
        scale: animate && !state.open ? 0.97 : 1,
      }}
      transition={{
        duration: Number(animate) * (state.open ? 0.18 : 0.12),
        ease: [0.22, 1, 0.36, 1],
      }}
      onAnimationComplete={() => {
        if (!openRef.current) {
          unmount?.();
        }
      }}
    >
      {nativeProps.children}
    </MotionPopupElement>
  );
};

export type PopoverContentProps = Primitive.Popup.Props &
  Pick<
    Primitive.Positioner.Props,
    | "align"
    | "alignOffset"
    | "side"
    | "sideOffset"
    | "collisionPadding"
    | "collisionAvoidance"
    | "anchor"
  > & { animated?: boolean };

export const PopoverContent = ({
  align = "start",
  className,
  side = "bottom",
  sideOffset = 6,
  alignOffset = 0,
  collisionPadding = 8,
  collisionAvoidance,
  anchor,
  animated = true,
  render,
  ...props
}: PopoverContentProps) => (
  // Keep the portal out of flex/grid flow without inheriting the static
  // position at the end of its container (which can scroll autofocus there).
  <Primitive.Portal style={{ left: 0, position: "absolute", top: 0 }}>
    <Primitive.Positioner
      align={align}
      side={side}
      sideOffset={sideOffset}
      alignOffset={alignOffset}
      collisionPadding={collisionPadding}
      collisionAvoidance={collisionAvoidance}
      anchor={anchor}
      className="isolate z-50"
    >
      <Primitive.Popup
        data-slot="popover-content"
        className={cn(
          "flex w-72 max-w-[min(var(--available-width),calc(100vw-1rem))] max-h-(--available-height) origin-(--transform-origin) flex-col gap-3 overflow-y-auto overscroll-contain rounded-md border bg-popover p-4 text-sm text-popover-foreground shadow-md outline-none",
          className
        )}
        {...props}
        render={(nativeProps, state) => (
          <AnimatedPopup
            nativeProps={nativeProps}
            render={render}
            state={state}
            animated={animated}
          />
        )}
      />
    </Primitive.Positioner>
  </Primitive.Portal>
);

export const PopoverHeader = ({
  className,
  ...props
}: ComponentProps<"div">) => (
  <div
    data-slot="popover-header"
    className={cn("flex flex-col gap-1", className)}
    {...props}
  />
);
export const PopoverTitle = ({
  className,
  ...props
}: Primitive.Title.Props) => (
  <Primitive.Title
    data-slot="popover-title"
    className={cn("font-medium leading-snug", className)}
    {...props}
  />
);
export const PopoverDescription = ({
  className,
  ...props
}: Primitive.Description.Props) => (
  <Primitive.Description
    data-slot="popover-description"
    className={cn("text-sm leading-relaxed text-muted-foreground", className)}
    {...props}
  />
);

```

## Credits [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/popover): Adapted Base Nova Popover composition, with Vandor styling and Motion transitions.
