Popover
A floating panel that is attached to a trigger element.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverPreview() {
return (
<Popover>
<Popover.Trigger asChild>
<Button variant="outline">Open Popover</Button>
</Popover.Trigger>
<Popover.Content>
<p>This is the content of the popover.</p>
</Popover.Content>
</Popover>
);
} Dependencies
Source Code
import {
autoUpdate,
type FloatingContext,
FloatingFocusManager,
flip,
hide,
offset as offsetMiddleware,
type Placement,
shift,
size,
type UseFloatingOptions,
type UseInteractionsReturn,
useClick,
useDismiss,
useFloating,
useInteractions,
useMergeRefs,
useRole,
useTransitionStatus,
} from '@floating-ui/react';
import { MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr';
import {
createContext,
use,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { useTopLayer } from '@/foundations/hooks/use-top-layer';
import { Spinner } from '@/components/spinner';
import { cn } from '@/lib/utils/classnames';
type PopoverOrigin = 'trigger' | 'pointer' | [number, number];
interface UsePopoverFloatingOptions {
open?: boolean;
onOpenChange?: UseFloatingOptions['onOpenChange'];
placement?: Placement;
offset?: number;
origin?: PopoverOrigin;
// Cross-axis fallbacks for `flip()`. Useful for nested menus on narrow
// viewports where neither side has horizontal room — e.g. ['left-start',
// 'bottom-start', 'top-start'] lets a `right-start` submenu fall back below
// the trigger.
flipFallbackPlacements?: Placement[];
// FloatingTree integration — used by Menu for nested submenu coordination.
nodeId?: string;
}
const usePopoverFloating = ({
open: propsOpen,
onOpenChange: propsOnOpenChange,
placement = 'bottom',
offset = 4,
origin = 'trigger',
flipFallbackPlacements,
nodeId,
}: 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({
nodeId,
placement,
open,
onOpenChange: setOpen,
// Pause `update()` while a text-input descendant of the floating element
// has focus. iOS fires scroll/resize on `window.visualViewport` when the
// soft keyboard opens — without this guard, those events trigger
// `flip()` to re-evaluate against the shrunken viewport, the panel
// re-renders with a new placement, and the focused input loses focus,
// dismissing the keyboard in a loop. The listeners live in
// `whileElementsMounted` so they're tied to floating-ui's lifecycle and
// don't need a separate `useEffect`.
whileElementsMounted: (reference, floatingEl, update) => {
let paused = false;
const isTextInput = (n: EventTarget | null) =>
n instanceof HTMLElement &&
(n.tagName === 'INPUT' ||
n.tagName === 'TEXTAREA' ||
n.isContentEditable);
const onFocusIn = (e: FocusEvent) => {
if (isTextInput(e.target)) paused = true;
};
const onFocusOut = (e: FocusEvent) => {
if (isTextInput(e.target)) paused = false;
};
floatingEl.addEventListener('focusin', onFocusIn);
floatingEl.addEventListener('focusout', onFocusOut);
const cleanup = autoUpdate(
reference,
floatingEl,
() => {
if (paused) return;
update();
},
{ layoutShift: false }
);
return () => {
cleanup();
floatingEl.removeEventListener('focusin', onFocusIn);
floatingEl.removeEventListener('focusout', onFocusOut);
};
},
middleware: [
flip({ padding: 8, fallbackPlacements: flipFallbackPlacements }),
shift({ padding: 8 }),
offsetMiddleware(offset),
size({
apply({ rects, elements, availableHeight }) {
elements.floating.style.setProperty(
'--max-height',
`${availableHeight}px`
);
elements.floating.style.setProperty(
'--width',
`${rects.reference.width}px`
);
},
padding: 4,
}),
hide(),
],
});
// When origin is an explicit [x, y], pin the floating element to that point
// via Floating UI's virtual reference pattern. The trigger element stays the
// interaction reference (focus, dismiss); only positioning changes. We don't
// reset to null in the 'trigger' / 'pointer' branches: setPositionReference
// is wired into Floating UI's lower-level setReference, and clearing it
// after the trigger's ref callback has already registered would break
// positioning entirely.
useEffect(() => {
if (Array.isArray(origin)) {
const [x, y] = origin;
floating.refs.setPositionReference({
getBoundingClientRect: () => ({
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
}),
});
}
}, [origin, floating.refs]);
return useMemo(
() => ({
open,
setOpen,
origin,
...floating,
}),
[open, setOpen, origin, 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>
* <Popover.Trigger>
* <Button>Open Popover</Button>
* </Popover.Trigger>
* <Popover.Content>
* <p>Popover Content</p>
* </Popover.Content>
* </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
* ```
* <Popover.Trigger>
* <Button>Open Popover</Button>
* </Popover.Trigger>
*
* <Popover.Trigger asChild={false}>
* Open Popover
* </Popover.Trigger>
* ```
*/
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]);
const referenceProps = context.getReferenceProps(props);
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
className={cn(!asChild && 'disabled:opacity-40', className)}
data-state={context.open ? 'open' : 'closed'}
{...referenceProps}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
// Pointer mode: capture the click coordinates and pin the floating
// panel there. Keyboard-triggered clicks have clientX/Y of 0 — we
// skip in that case and let positioning fall back to the trigger.
if (
context.origin === 'pointer' &&
!context.open &&
event.clientX &&
event.clientY
) {
const x = event.clientX;
const y = event.clientY;
context.refs.setPositionReference({
getBoundingClientRect: () => ({
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
}),
});
}
if (typeof referenceProps.onClick === 'function') {
referenceProps.onClick(event);
}
}}
>
{children}
</Comp>
);
};
/**
* Will render the popover content.
*
* @example
* ```
* <Popover.Content>
* <p>Popover Content</p>
* </Popover.Content>
* ```
*/
const PopoverContent = ({
ref: refProp,
className,
children,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const { context, refs, getFloatingProps, modal, isPositioned } =
usePopoverContext();
const ref = useMergeRefs([refs.setFloating, refProp]);
return (
<PopoverPanel
context={context}
modal={modal}
isPositioned={isPositioned}
ref={ref}
className={cn(
'z-50 max-h-(--max-height) w-72 overflow-auto rounded-xl border border-border bg-background p-3 font-medium text-foreground shadow-lg outline-none',
className
)}
{...getFloatingProps(props)}
>
{children}
</PopoverPanel>
);
};
interface PopoverPanelProps extends React.ComponentPropsWithRef<'div'> {
context: FloatingContext;
modal?: boolean;
isPositioned?: boolean;
initialFocus?: number | React.RefObject<HTMLElement | null>;
returnFocus?: boolean;
animate?: boolean;
}
/**
*
* PopoverPanel is the actual floating panel that will be positioned relative to the trigger.
* It's exported for internal purposes only, to avoid duplicating the logic of positioning and transitions.
* It is not part of the public API as it is included already in the PopoverContent component.
* @returns
*/
const PopoverPanel = ({
ref,
context,
modal,
isPositioned = true,
initialFocus,
returnFocus,
animate = true,
className,
style,
...props
}: PopoverPanelProps) => {
const { isMounted, status } = useTransitionStatus(context, {
duration: animate ? 150 : 0,
});
const topLayerRef = useTopLayer<HTMLDivElement>(isMounted);
const mergedRef = useMergeRefs([ref, topLayerRef]);
if (!isMounted) return null;
// Hide until floating-ui has computed the position. Otherwise the panel
// renders at (0, 0) on the first frame and FloatingFocusManager's autofocus
// makes the browser scroll the document toward that point before the real
// position is applied.
const hidden = !isPositioned || context.middlewareData.hide?.referenceHidden;
return (
<FloatingFocusManager
context={context}
modal={modal}
initialFocus={initialFocus}
returnFocus={returnFocus}
>
<div
ref={mergedRef}
data-state={['open', 'initial'].includes(status) ? 'open' : 'closed'}
data-side={context.placement.split('-')[0]}
className={cn(
animate && [
'origin-(--transform-origin) transition duration-300 ease-out',
'data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2 data-[state=closed]:data-[side=bottom]:-translate-y-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,
'--transform-origin': placementToTransformOrigin(context.placement),
visibility: hidden ? 'hidden' : 'visible',
...style,
}}
{...props}
/>
</FloatingFocusManager>
);
};
// 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>
* <Popover.Trigger>
* <Button>Open Popover</Button>
* </Popover.Trigger>
* <Popover.Content>
* <Popover.Close>
* <Button>Cancel</Button>
* </Popover.Close>
* </Popover.Content>
* </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>
);
};
interface PopoverSearchInputProps extends React.ComponentPropsWithRef<'input'> {
isLoading?: boolean;
}
const PopoverSearchInput = ({
className,
isLoading,
...props
}: PopoverSearchInputProps) => {
return (
<div className="relative flex items-center rounded-t-lg border-border border-b bg-transparent">
{isLoading ? (
<Spinner size="sm" className="absolute left-4 text-foreground" />
) : (
<MagnifyingGlassIcon
weight="bold"
className="absolute left-4 size-4 shrink-0 text-foreground"
/>
)}
<input
className={cn(
'h-10 w-full border-0 bg-transparent p-4 pl-10 font-medium text-base outline-none transition-colors placeholder:text-foreground-secondary focus:ring-0',
className
)}
{...props}
/>
</div>
);
};
const PopoverEmpty = ({
children,
className,
...props
}: React.ComponentPropsWithRef<'div'>) => {
return (
<div
className={cn(
'my-4 text-center text-base text-foreground-secondary',
className
)}
{...props}
>
{children}
</div>
);
};
const CompoundPopover = Object.assign(Popover, {
Trigger: PopoverTrigger,
Content: PopoverContent,
Close: PopoverClose,
SearchInput: PopoverSearchInput,
Empty: PopoverEmpty,
Panel: PopoverPanel,
});
export type { PopoverOrigin };
export {
CompoundPopover as Popover,
PopoverContext,
usePopoverContext,
// internal use only
usePopoverFloating,
}; 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>
<Popover.Trigger />
<Popover.Content>
<Popover.Close />
<Popover.SearchInput />
<Popover.Empty />
</Popover.Content>
</Popover>
API Reference
Popover
| Prop | Default | Type | Description |
|---|---|---|---|
open | - | boolean | Whether the popover is open. |
onOpenChange | - | (open: boolean) => void | Callback fired when the open state changes. |
placement | "bottom" | Placement | The placement of the popover relative to its trigger. |
offset | 4 | number | The distance between the popover and its trigger. |
origin | "trigger" | "trigger" | "pointer" | [number, number] | How the popover is anchored. `"trigger"` (default) anchors to the trigger element. `"pointer"` captures the click coordinates on the trigger and anchors there. A `[clientX, clientY]` tuple anchors to that explicit point — useful for right-click context menus. |
modal | false | boolean | Whether to trap focus inside the popover. |
Popover.Trigger
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to render the trigger as its child element. |
Popover.Content
Extends the div element.
The content will be rendered in a portal and will be positioned relative to the trigger.
Popover.Close
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to render the close button as its child element. |
Popover.SearchInput
Extends the input element.
A styled input with a search icon, useful for filtering content inside the popover.
| Prop | Default | Type | Description |
|---|---|---|---|
isLoading | false | boolean | When true, replaces the leading magnifying-glass icon with a spinner. Useful for async-filtered content. |
Popover.Empty
Extends the div element.
A styled container for empty state messages.
Examples
Simple
Basic usage of the popover component.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverPreview() {
return (
<Popover>
<Popover.Trigger asChild>
<Button variant="outline">Open Popover</Button>
</Popover.Trigger>
<Popover.Content>
<p>This is the content of the popover.</p>
</Popover.Content>
</Popover>
);
} Placement
Controlling the position of the popover relative to its trigger.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverPlacementPreview() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<Popover placement="top">
<Popover.Trigger asChild>
<Button variant="outline">Top</Button>
</Popover.Trigger>
<Popover.Content>
<p>This popover appears on top.</p>
</Popover.Content>
</Popover>
<Popover placement="bottom">
<Popover.Trigger asChild>
<Button variant="outline">Bottom</Button>
</Popover.Trigger>
<Popover.Content>
<p>This popover appears at the bottom.</p>
</Popover.Content>
</Popover>
<Popover placement="left">
<Popover.Trigger asChild>
<Button variant="outline">Left</Button>
</Popover.Trigger>
<Popover.Content>
<p>This popover appears on the left.</p>
</Popover.Content>
</Popover>
<Popover placement="right">
<Popover.Trigger asChild>
<Button variant="outline">Right</Button>
</Popover.Trigger>
<Popover.Content>
<p>This popover appears on the right.</p>
</Popover.Content>
</Popover>
</div>
);
} Anchored to the cursor
Set origin="pointer" to open the popover at the position where the trigger was clicked instead of next to the trigger element. For right-click context menus, pass origin={[clientX, clientY]} directly.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverPointerPreview() {
return (
<Popover origin="pointer">
<Popover.Trigger asChild>
<Button variant="outline">Click anywhere on me</Button>
</Popover.Trigger>
<Popover.Content className="w-min whitespace-nowrap">
<p>Here I am</p>
</Popover.Content>
</Popover>
);
} Modal
A modal popover will trap focus inside, making it ideal for forms and other interactive content.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverModalPreview() {
return (
<Popover modal>
<Popover.Trigger asChild>
<Button variant="outline">Open Modal Popover</Button>
</Popover.Trigger>
<Popover.Content className="flex flex-col gap-4">
<div>
<h3 className="mb-1 font-medium text-sm">This is a modal popover</h3>
<p className="text-foreground-secondary text-sm">
It will trap focus inside. Very useful for popovers with advanced
interactions inside (like forms)
</p>
</div>
<div className="flex items-center gap-2">
<Popover.Close asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</Popover.Close>
<Button type="submit">Submit</Button>
</div>
</Popover.Content>
</Popover>
);
} Custom Width
Example showing how to customize the popover width.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverCustomWidthPreview() {
return (
<Popover>
<Popover.Trigger asChild>
<Button variant="outline">Custom Width</Button>
</Popover.Trigger>
<Popover.Content className="w-96">
<p>This popover has a custom width of 24rem (w-96).</p>
<p className="mt-2 text-foreground-secondary text-sm">
You can customize the width of the popover by adding a width utility
class to the Popover.Content component.
</p>
</Popover.Content>
</Popover>
);
} Search Input
Using the built-in search input for filtering content.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverSearchPreview() {
return (
<Popover>
<Popover.Trigger asChild>
<Button variant="outline">Search</Button>
</Popover.Trigger>
<Popover.Content className="p-0">
<Popover.SearchInput placeholder="Search items..." />
<div className="p-1">Items would go here</div>
</Popover.Content>
</Popover>
);
} Empty State
Displaying a message when no content is available.
import { Button } from '@/components/button';
import { Popover } from '@/components/popover';
export default function PopoverEmptyPreview() {
return (
<Popover>
<Popover.Trigger asChild>
<Button variant="outline">Empty State</Button>
</Popover.Trigger>
<Popover.Content className="p-0">
<Popover.SearchInput placeholder="Search items..." />
<Popover.Empty>No items found</Popover.Empty>
</Popover.Content>
</Popover>
);
} 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
Previous
Pagination
Next
Portal