For the complete documentation index, see 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.
1
Support

Popover

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

Playground

Live preview
Click the trigger to preview motion. Escape or outside interaction dismisses. Initially open remounts the preview; Reset restores settings and closes it. Generated code uses the initial state, not transient opening.
Customize Popover
Customize
6.00
popover-demo.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

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/popover.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies3 required packages
  • @base-ui/react@^1.8.0
  • cn
  • motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn motion@^12.38.0

Usage

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 separately when using these examples.

Examples

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

popover-demo.tsx
"use client";

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

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

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.

Popover is closed
popover-controlled-demo.tsx
"use client";

import { useState } from "react";

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

export const PopoverControlledDemo = () => {
  const [open, setOpen] = useState(false);
  return (
    <div className="flex flex-col items-center gap-3">
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger render={<Button variant="outline">Review sharing</Button>} />
        <PopoverContent align="center" side="top">
          <PopoverTitle>Private workspace</PopoverTitle>
          <PopoverDescription>
            This example controls visibility only. Closing does not save or
            discard application data.
          </PopoverDescription>
          <PopoverClose render={<Button size="sm">Got it</Button>} />
        </PopoverContent>
      </Popover>
      <output className="text-xs text-muted-foreground">
        {open ? "Popover is open" : "Popover is closed"}
      </output>
    </div>
  );
};

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

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:

$ pnpm dlx 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

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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
actionsRefRefObject<Popover.Root.Actions | null>

Popover: imperative close/unmount actions. The component manages animation unmounting.

Not set
align"start" | "center" | "end"

PopoverContent: alignment relative to the trigger.

"start"
alignOffsetnumber

PopoverContent: offset along the alignment axis.

0
anchorPositioner.Props['anchor']

PopoverContent: optional alternate positioning anchor.

Trigger
animatedboolean

PopoverContent: Motion entrance and exit; reduced motion disables transitions.

true
collisionAvoidancePositioner.Props['collisionAvoidance']

PopoverContent: inherited flip/shift placement policy.

Base UI default
collisionPaddingnumber | Partial<Record<Side, number>>

PopoverContent: padding from collision boundaries.

8
defaultOpenboolean

Popover: initial uncontrolled state. Changing this control remounts the preview.

false
disabledboolean

PopoverTrigger: prevents trigger interaction, not programmatic opening.

false
finalFocusPopup.Props['finalFocus']

PopoverContent: controls focus restoration when closed.

Base UI default
initialFocusPopup.Props['initialFocus']

PopoverContent: controls focus when opened; touch behavior follows Base UI.

Base UI default
modalboolean | "trap-focus"

Popover: modal interaction policy. Include PopoverClose for modal accessibility.

false
onOpenChange(open, details) => void

Popover: receives requested state and Base UI reason. details.cancel() rejects the change.

Not set
onOpenChangeComplete(open: boolean) => void

Popover: notified after the opening or closing lifecycle completes.

Not set
openboolean

Popover: controlled open state; pair with onOpenChange.

Not set
renderReactElement | ((props, state) => ReactElement)

Trigger, Close, Title, Description, Content: compose a custom element while preserving primitive props and refs.

Not set
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"

PopoverContent: preferred side; collision avoidance can flip it.

"bottom"
sideOffsetnumber

PopoverContent: distance from the trigger in pixels.

6
titleReactNode

PopoverTitle: accessible popup heading, composed inside PopoverHeader.

Not set

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

"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

  • shadcn/ui: Adapted Base Nova Popover composition, with Vandor styling and Motion transitions.