Popover
SaveA floating panel anchored to a trigger, built on Base UI Popover, that grows from the trigger and has an optional hairline arrow.
- Category
- Core
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Popover preview
Installation
npx @seamlessui/cli add popoverPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add popover --dry-runUsage
popover-example.tsx
import { Popover, PopoverClose, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/seamless/ui/popover";
// Same recipe as the Button component's secondary variant, inlined so this example has no other dependency.
const button =
"inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-zinc-950/12 bg-white px-3.5 text-sm font-medium text-zinc-950 select-none " +
"transition-[background-color,border-color,scale] duration-150 ease-out active:duration-100 motion-safe:active:scale-[0.97] motion-reduce:transition-none " +
"hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] data-[popup-open]:bg-zinc-950/[0.04] " +
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 " +
"dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06] dark:data-[popup-open]:bg-white/[0.06]";
export default function PopoverExample() {
return (
<Popover>
<PopoverTrigger className={button}>Share</PopoverTrigger>
<PopoverContent arrow className="grid w-80 gap-3">
<div className="grid gap-0.5">
<PopoverTitle>Share this page</PopoverTitle>
<PopoverDescription>Anyone with the link can view it.</PopoverDescription>
</div>
<input
readOnly
aria-label="Page link"
value="https://acme.dev/docs/limits"
className={
"h-8 rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-2.5 font-mono text-xs text-zinc-700 " +
"focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none " +
"dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300"
}
/>
<PopoverClose className={`${button} h-8 justify-self-end px-3`}>Done</PopoverClose>
</PopoverContent>
</Popover>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| Popover open / defaultOpen / onOpenChange | boolean / boolean / (open, details) => void | — | Root state. Leave open unset for an uncontrolled popover. |
| Popover modal | boolean | "trap-focus" | false | Traps focus and blocks the page while open. Off by default. |
| PopoverTrigger | Base UI Popover.Trigger props | — | Unstyled button that toggles the popover. Pass className, or render={<Button />}. openOnHover is supported. |
| PopoverContent side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "bottom" | Preferred side of the trigger. Flips when there is not enough room. |
| PopoverContent align | "start" | "center" | "end" | "center" | Alignment against the trigger. |
| PopoverContent sideOffset / alignOffset | number | 8 / 0 | Gap from the trigger and shift along its edge, in pixels. |
| PopoverContent arrow | boolean | false | Shows a 16 by 9 notch drawn in the panel fill and border color, pointing at the trigger. Adds 2px to sideOffset so the tip clears the trigger. |
| PopoverContent positionerClassName | string | — | Classes for the positioner, for example a different z-index. |
| PopoverTitle / PopoverDescription | Base UI Popover.Title / Popover.Description props | — | Optional heading and text, wired to aria-labelledby and aria-describedby. |
| PopoverClose | Base UI Popover.Close props | — | Unstyled button that closes the popover. |
Accessibility
- The trigger sets aria-expanded and aria-controls; the panel has role="dialog", named by PopoverTitle when present.
- Opening moves focus into the panel and closing returns it to the trigger.
- Escape and a click outside close the popover.
- The arrow is decorative and hidden from assistive technology.
- The panel scales from the trigger side (origin-aware) in 160ms and fades out in 110ms; with reduced motion only the opacity changes, and transitions are removed entirely.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/popover.tsxpopover- npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/popover/0.2.0.json.
ui/popover.tsxShowHide
ui/popover.tsx
"use client";
import { Popover as BasePopover } from "@base-ui/react/popover";
type ClassName<State> = string | ((state: State) => string | undefined) | undefined;
function cx(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
/** Prepends base classes while keeping Base UI's state-function form of className working. */
function withBase<State>(base: string, className: ClassName<State>) {
return typeof className === "function" ? (state: State) => cx(base, className(state)) : cx(base, className);
}
/** Groups the popover parts and holds its open state. Pass `open`/`onOpenChange` to control it. */
export const Popover = BasePopover.Root;
/** A button that toggles the popover. Unstyled; pass `className`, or `render={<Button />}` to reuse your button. */
export const PopoverTrigger = BasePopover.Trigger;
/** A button that closes the popover. Unstyled; pass `className` or `render`. */
export const PopoverClose = BasePopover.Close;
type PositionerProps = BasePopover.Positioner.Props;
export interface PopoverContentProps extends BasePopover.Popup.Props {
/** Side of the trigger to place the popover on. Flips when there is not enough room. */
side?: PositionerProps["side"];
/** Alignment against the trigger on the chosen side. */
align?: PositionerProps["align"];
/** Gap between the trigger and the popover, in pixels. */
sideOffset?: PositionerProps["sideOffset"];
/** Shift along the trigger edge, in pixels. */
alignOffset?: PositionerProps["alignOffset"];
/** Shows an arrow pointing at the trigger. */
arrow?: boolean;
/** Classes for the positioner wrapper, for example to change the z-index. */
positionerClassName?: string;
/** Element to portal into. Defaults to `document.body`. */
container?: BasePopover.Portal.Props["container"];
}
/** The floating panel, rendered in a portal and positioned against the trigger. */
export function PopoverContent({
side = "bottom",
align = "center",
sideOffset = 8,
alignOffset = 0,
arrow = false,
positionerClassName,
container,
className,
children,
...props
}: PopoverContentProps) {
return (
<BasePopover.Portal container={container}>
<BasePopover.Positioner
side={side}
align={align}
sideOffset={arrow && typeof sideOffset === "number" ? sideOffset + 2 : sideOffset}
alignOffset={alignOffset}
className={cx("z-50", positionerClassName)}
>
<BasePopover.Popup
className={withBase(
cx(
"relative w-72 max-w-(--available-width) origin-(--transform-origin) rounded-xl p-4 text-sm",
"border border-zinc-950/10 bg-white text-zinc-950 shadow-[0_12px_32px_-8px_rgb(0_0_0/0.18)]",
"dark:border-white/15 dark:bg-black dark:text-white dark:shadow-[0_16px_48px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)]",
"outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
// Enter: 160ms from 96% at the trigger. Exit: 110ms, fades with a smaller scale.
"transition-[opacity,scale] duration-160 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
"data-[starting-style]:opacity-0 motion-safe:data-[starting-style]:scale-96",
"data-[ending-style]:opacity-0 data-[ending-style]:duration-110 data-[ending-style]:ease-out motion-safe:data-[ending-style]:scale-98",
),
className,
)}
{...props}
>
{arrow ? <PopoverArrow /> : null}
{children}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
);
}
/** A 16 x 9 notch drawn in the panel's own fill and hairline, so it reads as part of the border. */
function PopoverArrow() {
return (
<BasePopover.Arrow
className={cx(
"flex",
"data-[side=bottom]:-top-[9px]",
"data-[side=top]:-bottom-[9px] data-[side=top]:rotate-180",
"data-[side=left]:-right-[12.5px] data-[side=left]:rotate-90",
"data-[side=right]:-left-[12.5px] data-[side=right]:-rotate-90",
)}
>
<svg aria-hidden="true" width="16" height="9" viewBox="0 0 16 9" fill="none">
<path d="M0 9.5L8 1.5L16 9.5Z" className="fill-white dark:fill-black" />
<path d="M0 9L8 1L16 9" strokeWidth="1" className="stroke-zinc-950/10 dark:stroke-white/15" />
</svg>
</BasePopover.Arrow>
);
}
/** Optional heading. Labels the popover with aria-labelledby. */
export function PopoverTitle({ className, ...props }: BasePopover.Title.Props) {
return <BasePopover.Title className={withBase("text-sm leading-5 font-semibold", className)} {...props} />;
}
/** Optional supporting text. Describes the popover with aria-describedby. */
export function PopoverDescription({ className, ...props }: BasePopover.Description.Props) {
return <BasePopover.Description className={withBase("text-sm leading-5 text-zinc-600 dark:text-zinc-400", className)} {...props} />;
}