- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"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:
- @base-ui/react@^1.8.0
- cn
- motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn motion@^12.38.0Usage
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.
"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.
"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
| Prop | Type / Description | Default |
|---|---|---|
| actionsRef | RefObject<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" |
| alignOffset | numberPopoverContent: offset along the alignment axis. | 0 |
| anchor | Positioner.Props['anchor']PopoverContent: optional alternate positioning anchor. | Trigger |
| animated | booleanPopoverContent: Motion entrance and exit; reduced motion disables transitions. | true |
| collisionAvoidance | Positioner.Props['collisionAvoidance']PopoverContent: inherited flip/shift placement policy. | Base UI default |
| collisionPadding | number | Partial<Record<Side, number>>PopoverContent: padding from collision boundaries. | 8 |
| defaultOpen | booleanPopover: initial uncontrolled state. Changing this control remounts the preview. | false |
| disabled | booleanPopoverTrigger: prevents trigger interaction, not programmatic opening. | false |
| finalFocus | Popup.Props['finalFocus']PopoverContent: controls focus restoration when closed. | Base UI default |
| initialFocus | Popup.Props['initialFocus']PopoverContent: controls focus when opened; touch behavior follows Base UI. | Base UI default |
| modal | boolean | "trap-focus"Popover: modal interaction policy. Include PopoverClose for modal accessibility. | false |
| onOpenChange | (open, details) => voidPopover: receives requested state and Base UI reason. details.cancel() rejects the change. | Not set |
| onOpenChangeComplete | (open: boolean) => voidPopover: notified after the opening or closing lifecycle completes. | Not set |
| open | booleanPopover: controlled open state; pair with onOpenChange. | Not set |
| render | ReactElement | ((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" |
| sideOffset | numberPopoverContent: distance from the trigger in pixels. | 6 |
| title | ReactNodePopoverTitle: 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.