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.
Install#
npx shadcn@latest add @pixilui/popoverOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/popover.json
- 1
Install the npm packages.
npm install @base-ui/react motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"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">)} /> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/popover.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.
Preview#
The panel grows out of the exact corner of its trigger on a snappy spring, wherever Base UI places it.
Code#
"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#
Use it when
- 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.
Use something else when
- 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.
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>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | What the panel holds: PopoverTitle, PopoverDescription, controls, PopoverClose. | |
triggerrequired | 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 takes 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#
Also in the markdown page and llms.txt
- 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.