Popover
A floating panel that is attached to a trigger element.
Dependencies
Source Code
"use client";
import { createContext, useCallback, use, useMemo, useState } from "react";
import {
autoUpdate,
flip,
Placement,
shift,
useFloating,
UseFloatingOptions,
offset as offsetMiddleware,
size,
hide,
useClick,
useDismiss,
useRole,
useInteractions,
useMergeRefs,
useTransitionStatus,
FloatingPortal,
FloatingFocusManager,
UseInteractionsReturn,
} from "@floating-ui/react";
import { Slot } from "@/components/slot";
import { MagnifyingGlass } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
interface UsePopoverFloatingOptions {
open?: boolean;
onOpenChange?: UseFloatingOptions["onOpenChange"];
placement?: Placement;
offset?: number;
}
const usePopoverFloating = ({
open: propsOpen,
onOpenChange: propsOnOpenChange,
placement = "bottom",
offset = 4,
}: UsePopoverFloatingOptions) => {
const [internalOpen, setInternalOpen] = useState(false);
const open = propsOpen ?? internalOpen;
const setOpen = useCallback<NonNullable<UseFloatingOptions["onOpenChange"]>>(
(open, event, reason) => {
setInternalOpen(open);
propsOnOpenChange?.(open, event, reason);
},
[propsOnOpenChange]
);
const floating = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
layoutShift: false,
}),
middleware: [
flip({
padding: 8,
}),
shift({
padding: 8,
}),
offsetMiddleware(offset),
size({
apply({ elements, availableHeight }) {
elements.floating.style.setProperty(
"--max-height",
`${availableHeight}px`
);
},
padding: 4,
}),
hide(),
],
});
return useMemo(
() => ({
open,
setOpen,
...floating,
}),
[open, setOpen, floating]
);
};
// Context
interface ContextType
extends ReturnType<typeof usePopoverFloating>,
UseInteractionsReturn {
modal: boolean;
}
const PopoverContext = createContext<ContextType | null>(null);
const usePopoverContext = () => {
const context = use(PopoverContext);
if (context == null) {
throw new Error("Popover components must be wrapped in <Popover />");
}
return context;
};
// Components
interface PopoverProps extends UsePopoverFloatingOptions {
modal?: boolean;
children: React.ReactNode;
}
/**
* Popover allows you to open a floating panel that is attached to a trigger element.
*
* Set `modal` to `true` to focus trap the popover.
*
* @example
* ```
* <Popover>
* <PopoverTrigger>
* <Button>Open Popover</Button>
* </PopoverTrigger>
* <PopoverContent>
* <p>Popover Content</p>
* </PopoverContent>
* </Popover>
* ```
*/
const Popover = ({ children, modal = false, ...props }: PopoverProps) => {
const floating = usePopoverFloating(props);
const click = useClick(floating.context);
const dismiss = useDismiss(floating.context);
const role = useRole(floating.context);
const interactions = useInteractions([click, dismiss, role]);
const popoverContextValue = useMemo(
() => ({
...floating,
...interactions,
modal,
}),
[floating, interactions, modal]
);
return (
<PopoverContext value={popoverContextValue}>{children}</PopoverContext>
);
};
interface PopoverTriggerProps extends React.ComponentPropsWithRef<"button"> {
asChild?: boolean;
}
/**
* Will open the popover when clicked.
*
* Use `asChild` to render as your child element.
*
* @example
* ```
* <PopoverTrigger>
* <Button>Open Popover</Button>
* </PopoverTrigger>
*
* <PopoverTrigger asChild={false}>
* Open Popover
* </PopoverTrigger>
* ```
*/
const PopoverTrigger = ({
ref: refProp,
children,
asChild = false,
className,
...props
}: PopoverTriggerProps) => {
const context = usePopoverContext();
const Comp = asChild ? Slot : "button";
const ref = useMergeRefs([context.refs.setReference, refProp]);
return (
<Comp
ref={ref}
type={asChild ? undefined : "button"}
className={cn(!asChild && "disabled:opacity-40", className)}
data-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</Comp>
);
};
/**
* Will render the popover content.
*
* @example
* ```
* <PopoverContent>
* <p>Popover Content</p>
* </PopoverContent>
* ```
*/
const PopoverContent = ({
ref: refProp,
style,
className,
children,
...props
}: React.ComponentPropsWithRef<"div">) => {
const { context, refs, getFloatingProps, modal } = usePopoverContext();
const ref = useMergeRefs([refs.setFloating, refProp]);
const { isMounted, status } = useTransitionStatus(context, {
duration: 150,
});
if (!isMounted) return null;
return (
<FloatingPortal>
<FloatingFocusManager context={context} modal={modal}>
<div
data-state={["open", "initial"].includes(status) ? "open" : "closed"}
data-side={context.placement.split("-")[0]}
{...getFloatingProps({
ref,
className: cn(
"z-50 w-72 overflow-auto rounded-xl border border-border bg-background p-3 text-foreground shadow-lg outline-none max-h-(--max-height)",
"origin-(--popover-transform-origin) transition duration-300 ease-out-expo",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2 data-[state=closed]:data-[side=top]:translate-y-2",
"data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=closed]:duration-150",
"data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 data-[state=open]:scale-100",
className
),
style: {
position: context.strategy,
top: context.y ?? 0,
left: context.x ?? 0,
"--popover-transform-origin": placementToTransformOrigin(
context.placement
),
visibility: context.middlewareData.hide?.referenceHidden
? "hidden"
: "visible",
...style,
},
...props,
})}
>
{children}
</div>
</FloatingFocusManager>
</FloatingPortal>
);
};
// ugly and verbose but easy to reason about and maintain
const placementToTransformOrigin = (placement: Placement) => {
switch (placement) {
case "top":
return "bottom";
case "bottom":
return "top";
case "left":
return "right";
case "right":
return "left";
case "top-start":
return "bottom left";
case "top-end":
return "bottom right";
case "bottom-start":
return "top left";
case "bottom-end":
return "top right";
case "left-start":
return "right top";
case "left-end":
return "right bottom";
case "right-start":
return "left top";
case "right-end":
return "left bottom";
}
};
interface PopoverCloseProps extends React.ComponentPropsWithRef<"button"> {
asChild?: boolean;
}
/**
* Will close the popover when clicked.
*
* Useful to dismiss the popover from within (e.g.: popover with a form and a cancel button).
*
* Use `asChild` to render as your child element.
*
* @example
* ```
* <Popover>
* <PopoverTrigger>
* <Button>Open Popover</Button>
* </PopoverTrigger>
* <PopoverContent>
* <PopoverClose>
* <Button>Cancel</Button>
* </PopoverClose>
* </PopoverContent>
* </Popover>
* ```
*/
const PopoverClose = ({
asChild = false,
children,
...props
}: PopoverCloseProps) => {
const { setOpen } = usePopoverContext();
const Comp = asChild ? Slot : "button";
return (
<Comp
{...props}
onClick={(event: React.MouseEvent<HTMLElement>) => {
props.onClick?.(event as React.MouseEvent<HTMLButtonElement>);
setOpen(false);
}}
>
{children}
</Comp>
);
};
const PopoverSearchInput = ({
className,
...props
}: React.ComponentPropsWithRef<"input">) => {
return (
<div className="border-border relative mb-1 flex items-center rounded-t-lg border-b bg-transparent">
<MagnifyingGlass
weight="bold"
className="text-foreground absolute left-4 size-4 shrink-0"
/>
<input
className={cn(
"placeholder:text-foreground-secondary h-10 w-full border-0 bg-transparent p-4 pl-10 text-base font-medium transition-colors outline-none focus:ring-0",
className
)}
{...props}
/>
</div>
);
};
const PopoverEmpty = ({
children,
className,
...props
}: React.ComponentPropsWithRef<"div">) => {
return (
<div
className={cn(
"text-foreground-secondary pt-4 pb-5 text-center text-base",
className
)}
{...props}
>
{children}
</div>
);
};
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverClose,
PopoverSearchInput,
PopoverEmpty,
usePopoverFloating,
PopoverContext,
usePopoverContext,
};
Features
- Smart Positioning: Automatically adjusts position to stay in view
- Focus Management: Optional modal mode with focus trapping
- Search Support: Built-in search input component
- Empty States: Dedicated component for empty state messages
- Flexible Triggers: Support for custom trigger elements
Anatomy
<Popover>
<PopoverTrigger />
<PopoverContent>
<PopoverClose />
<PopoverSearchInput />
<PopoverEmpty />
</PopoverContent>
</Popover>
API Reference
Popover
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether the popover is open. |
| - |
| Callback fired when the open state changes. |
|
|
| The placement of the popover relative to its trigger. |
|
|
| The distance between the popover and its trigger. |
|
|
| Whether to trap focus inside the popover. |
PopoverTrigger
Extends the button
element.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether to render the trigger as its child element. |
PopoverContent
Extends the div
element.
The content will be rendered in a portal and will be positioned relative to the trigger.
PopoverClose
Extends the button
element.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether to render the close button as its child element. |
PopoverSearchInput
Extends the input
element.
A styled input with a search icon, useful for filtering content inside the popover.
PopoverEmpty
Extends the div
element.
A styled container for empty state messages.
Examples
Simple
Basic usage of the popover component.
Placement
Controlling the position of the popover relative to its trigger.
Modal
A modal popover will trap focus inside, making it ideal for forms and other interactive content.
Custom Width
Example showing how to customize the popover width.
Search Input
Using the built-in search input for filtering content.
Empty State
Displaying a message when no content is available.
Best Practices
-
Positioning:
- Consider available screen space
- Adjust offset based on content
- Test on different screen sizes
-
Focus Management:
- Use modal mode for complex interactions
- Ensure keyboard navigation works
- Provide clear focus indicators
-
Content:
- Keep content concise
- Use appropriate width for content
- Consider mobile interactions