Popover
Contextual details on demand. Non-blocking UI. Keep context.
1'use client';23import { Button } from '@/components/ui/button';4import { Input } from '@/components/ui/input';5import { Popover } from '@/components/ui/popover';67export function Default() {8 return (9 <Popover>10 <Popover.Trigger asChild>11 <Button variant="ghost">acme-web</Button>12 </Popover.Trigger>13 <Popover.Content aria-label="Rename project" className="w-80 max-w-[calc(100vw-2rem)]">14 <div className="grid gap-4">15 <div className="space-y-1">16 <h4 className="text-sm leading-none font-medium">Rename project</h4>17 <p className="text-muted-foreground text-xs">18 The old name keeps redirecting for 30 days.19 </p>20 </div>21 <Input22 id="project-slug"23 label="Project name"24 defaultValue="acme-web"25 description="Preview URL: acme-web.northwind.app"26 />27 <div className="flex justify-end gap-2">28 <Popover.Close asChild>29 <Button variant="outline" size="sm">30 Cancel31 </Button>32 </Popover.Close>33 <Popover.Close asChild>34 <Button size="sm">Rename</Button>35 </Popover.Close>36 </div>37 </div>38 </Popover.Content>39 </Popover>40 );41}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { FloatingPortal, useFloating } from '../lib/floating';7import { floatingOrigin, floatingVariants, reveal } from '../lib/motion';8import { assignRef, getFocusable, useLayer } from '../lib/overlay';9import { useControllableState } from '../lib/use-controllable-state';1011type IconProps = React.SVGProps<SVGSVGElement> & {12 size?: number | string;13};1415function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {16 return (17 <svg18 xmlns="http://www.w3.org/2000/svg"19 width={size}20 height={size}21 viewBox="0 0 24 24"22 fill="none"23 stroke="currentColor"24 strokeWidth={strokeWidth}25 strokeLinecap="round"26 strokeLinejoin="round"27 aria-hidden="true"28 {...props}29 >30 <path d="M18 6 6 18" />31 <path d="m6 6 12 12" />32 </svg>33 );34}3536const POPOVER_STYLE = { willChange: 'opacity, transform, filter' } as const;3738type PopoverSide = 'top' | 'bottom' | 'left' | 'right';3940function nextFocusableAfter(anchor: HTMLElement, skip: HTMLElement) {41 return getFocusable(document.body).find(42 (element) =>43 !skip.contains(element) &&44 !anchor.contains(element) &&45 Boolean(anchor.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING),46 );47}4849interface PopoverContextType {50 open: boolean;51 setOpen: (open: boolean) => void;52 close: () => void;53 id: string;54 triggerId: string | undefined;55 registerTrigger: (node: HTMLButtonElement | null) => void;56 triggerRef: React.RefObject<HTMLButtonElement | null>;57 contentRef: React.RefObject<HTMLDivElement | null>;58}5960const PopoverContext = React.createContext<PopoverContextType | undefined>(undefined);6162const usePopoverContext = () => {63 const context = React.use(PopoverContext);64 if (!context) {65 throw new Error('usePopoverContext must be used within a Popover');66 }67 return context;68};6970interface PopoverProps {71 children: React.ReactNode;72 defaultOpen?: boolean;73 open?: boolean;74 onOpenChange?: (open: boolean) => void;75}7677const PopoverRoot = ({78 children,79 defaultOpen = false,80 open: controlledOpen,81 onOpenChange,82}: PopoverProps) => {83 const [open, setOpen] = useControllableState({84 value: controlledOpen,85 defaultValue: defaultOpen,86 onChange: onOpenChange,87 });88 const id = React.useId();89 const containerRef = React.useRef<HTMLSpanElement>(null);90 const triggerRef = React.useRef<HTMLButtonElement>(null);91 const contentRef = React.useRef<HTMLDivElement>(null);92 const [triggerId, setTriggerId] = React.useState<string | undefined>(undefined);93 const layer = useLayer(open, false, contentRef);9495 const registerTrigger = React.useCallback((node: HTMLButtonElement | null) => {96 triggerRef.current = node;97 setTriggerId(node?.id || undefined);98 }, []);99100 const close = React.useCallback(() => {101 setOpen(false);102 triggerRef.current?.focus();103 }, [setOpen]);104105 React.useEffect(() => {106 if (!open) return;107108 const handlePointerDownOutside = (event: PointerEvent) => {109 const target = event.target as Node | null;110 if (!target) return;111 if (containerRef.current?.contains(target)) return;112 if (contentRef.current?.contains(target)) return;113 if (layer.isInsideUpperLayer(target)) return;114 setOpen(false);115 };116117 const handleEscape = (event: KeyboardEvent) => {118 if (event.key !== 'Escape' || event.defaultPrevented || !layer.isTopmost()) return;119 event.preventDefault();120 close();121 };122123 document.addEventListener('pointerdown', handlePointerDownOutside);124 document.addEventListener('keydown', handleEscape);125 return () => {126 document.removeEventListener('pointerdown', handlePointerDownOutside);127 document.removeEventListener('keydown', handleEscape);128 };129 }, [open, setOpen, close, layer]);130131 const context = React.useMemo(132 () => ({ open, setOpen, close, id, triggerId, registerTrigger, triggerRef, contentRef }),133 [open, setOpen, close, id, triggerId, registerTrigger],134 );135136 return (137 <PopoverContext value={context}>138 <span ref={containerRef} className="relative inline-flex">139 {children}140 </span>141 </PopoverContext>142 );143};144145interface PopoverTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {146 asChild?: boolean;147}148149type SlotChildProps = React.HTMLAttributes<HTMLElement> & {150 ref?: React.Ref<HTMLElement>;151 onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;152 onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => void;153};154155const PopoverTrigger = ({156 asChild,157 onClick,158 onKeyDown,159 children,160 className,161 style,162 ref,163 ...props164}: PopoverTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {165 const { open, setOpen, close, id, registerTrigger, contentRef } = usePopoverContext();166 const childRef = React.isValidElement(children)167 ? (children.props as SlotChildProps).ref168 : undefined;169170 const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {171 onClick?.(event);172 if (event.defaultPrevented) return;173 if (open) close();174 else setOpen(true);175 };176177 const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {178 onKeyDown?.(event);179 if (event.defaultPrevented || event.key !== 'Tab' || event.shiftKey || !open) return;180 const content = contentRef.current;181 if (!content) return;182 event.preventDefault();183 (getFocusable(content)[0] ?? content).focus();184 };185186 const mergedRef = React.useCallback(187 (node: HTMLButtonElement | null) => {188 registerTrigger(node);189 assignRef(ref, node);190 assignRef(childRef as React.Ref<HTMLButtonElement | null> | undefined, node);191 },192 [ref, childRef, registerTrigger],193 );194195 const shared = {196 id: props.id ?? `${id}-trigger`,197 'aria-expanded': open,198 'aria-haspopup': 'dialog' as const,199 'aria-controls': open ? id : undefined,200 'data-state': open ? 'open' : 'closed',201 };202203 if (asChild && React.isValidElement(children)) {204 const child = children as React.ReactElement<SlotChildProps>;205 const childProps = child.props;206207 return React.cloneElement(child, {208 ...props,209 ...childProps,210 ...shared,211 id: childProps.id ?? shared.id,212 ref: mergedRef,213 className: cn(className, childProps.className),214 style: style || childProps.style ? { ...style, ...childProps.style } : undefined,215 onClick: (event: React.MouseEvent<HTMLButtonElement>) => {216 childProps.onClick?.(event);217 handleClick(event);218 },219 onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => {220 childProps.onKeyDown?.(event);221 handleKeyDown(event);222 },223 } as SlotChildProps);224 }225226 return (227 <button228 type="button"229 className={className}230 style={style}231 {...props}232 {...shared}233 ref={mergedRef}234 onClick={handleClick}235 onKeyDown={handleKeyDown}236 >237 {children}238 </button>239 );240};241PopoverTrigger.displayName = 'PopoverTrigger';242243const PopoverClose = ({244 asChild,245 onClick,246 children,247 className,248 style,249 ref,250 ...props251}: React.ButtonHTMLAttributes<HTMLButtonElement> & {252 asChild?: boolean;253 ref?: React.Ref<HTMLButtonElement>;254}) => {255 const { close } = usePopoverContext();256257 const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {258 onClick?.(event);259 if (!event.defaultPrevented) close();260 };261262 if (asChild && React.isValidElement(children)) {263 const child = children as React.ReactElement<SlotChildProps>;264 const childProps = child.props;265 const childRef = childProps.ref;266267 return React.cloneElement(child, {268 ...props,269 ...childProps,270 ref: (node: HTMLButtonElement | null) => {271 assignRef(ref, node);272 assignRef(childRef as React.Ref<HTMLButtonElement | null> | undefined, node);273 },274 className: cn(className, childProps.className),275 style: style || childProps.style ? { ...style, ...childProps.style } : undefined,276 onClick: (event: React.MouseEvent<HTMLButtonElement>) => {277 childProps.onClick?.(event);278 handleClick(event);279 },280 } as SlotChildProps);281 }282283 return (284 <button285 type="button"286 className={className}287 style={style}288 {...props}289 ref={ref}290 onClick={handleClick}291 >292 {children}293 </button>294 );295};296PopoverClose.displayName = 'PopoverClose';297298interface PopoverContentProps extends HTMLMotionProps<'div'> {299 side?: PopoverSide;300 sideOffset?: number;301 children?: React.ReactNode;302 showClose?: boolean;303 closeLabel?: string;304}305306const PopoverPanel = ({307 side: preferredSide = 'bottom',308 sideOffset = 4,309 className,310 children,311 showClose = false,312 closeLabel = 'Close',313 style,314 onKeyDown,315 ref,316 'aria-label': ariaLabel,317 'aria-labelledby': ariaLabelledBy,318 ...props319}: PopoverContentProps & { ref?: React.Ref<HTMLDivElement> }) => {320 const { id, close, setOpen, triggerId, triggerRef, contentRef } = usePopoverContext();321 const shouldReduceMotion = useReducedMotion();322 const floating = useFloating({323 open: true,324 anchorRef: triggerRef,325 floatingRef: contentRef,326 side: preferredSide,327 sideOffset,328 });329 const side = floating.side;330331 React.useEffect(() => {332 const frame = requestAnimationFrame(() => {333 contentRef.current?.focus();334 });335 return () => cancelAnimationFrame(frame);336 }, [contentRef]);337338 const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {339 onKeyDown?.(event);340 if (event.defaultPrevented || event.key !== 'Tab') return;341 const content = contentRef.current;342 const trigger = triggerRef.current;343 if (!content || !trigger) return;344345 const focusable = getFocusable(content);346 const first = focusable[0];347 const last = focusable[focusable.length - 1];348 const active = document.activeElement;349350 if (event.shiftKey) {351 if (active === content || active === first || !first) {352 event.preventDefault();353 trigger.focus();354 }355 return;356 }357358 if (active === last || !last) {359 event.preventDefault();360 setOpen(false);361 (nextFocusableAfter(trigger, content) ?? trigger).focus();362 }363 };364365 return (366 <motion.div367 ref={(node: HTMLDivElement | null) => {368 contentRef.current = node;369 assignRef(ref, node);370 }}371 id={id}372 role="dialog"373 aria-label={ariaLabel}374 aria-labelledby={ariaLabelledBy ?? (ariaLabel ? undefined : triggerId)}375 tabIndex={-1}376 data-side={side}377 variants={shouldReduceMotion ? reveal : floatingVariants[side]}378 initial="hidden"379 animate="visible"380 exit="exit"381 style={{ ...POPOVER_STYLE, ...style, ...floating.style }}382 onKeyDown={handleKeyDown}383 className={cn(384 'bg-popover text-popover-foreground border-border z-500 max-h-(--available-height) w-72 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border p-4 shadow-md outline-none',385 floatingOrigin[side],386 className,387 )}388 {...props}389 >390 {children}391 {showClose && (392 <button393 type="button"394 onClick={close}395 className="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 rounded-md p-2 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"396 >397 <XIcon className="h-4 w-4" size={16} />398 <span className="sr-only">{closeLabel}</span>399 </button>400 )}401 </motion.div>402 );403};404405const PopoverContent = (props: PopoverContentProps & { ref?: React.Ref<HTMLDivElement> }) => {406 const { open } = usePopoverContext();407408 return (409 <FloatingPortal>410 <AnimatePresence>{open && <PopoverPanel {...props} />}</AnimatePresence>411 </FloatingPortal>412 );413};414PopoverContent.displayName = 'PopoverContent';415416const Popover = Object.assign(PopoverRoot, {417 Trigger: PopoverTrigger,418 Content: PopoverContent,419 Close: PopoverClose,420});421422export { Popover };423export type { PopoverProps, PopoverTriggerProps, PopoverContentProps, PopoverSide };
Update the import paths to match your project structure.
Anatomy
1import { Popover } from '@/components/ui/popover';
1<Popover>2 <Popover.Trigger>Open</Popover.Trigger>3 <Popover.Content>4 <p>Interactive content here.</p>5 </Popover.Content>6</Popover>
The content renders in a portal on
document.body, so overflow: hidden on a parent never clips it. It flips to the other side when there is no room and stays inside the viewport while the page scrolls.Focus moves into the content when it opens.
Escape, Popover.Close and the built-in close button all close it and put focus back on the trigger; a click outside closes it and leaves focus where you clicked. Tab moves through the content and then on to whatever follows the trigger on the page, and Shift+Tab from the first control goes back to the trigger.The content is a
dialog named after its trigger. Pass aria-label or aria-labelledby to Popover.Content when the trigger text alone does not describe it, for example an icon button.Composition
Use the following composition to build a
Popover:Popoverโโโ Popover.Triggerโโโ Popover.Contentโโโ Popover.Close
Examples
Default
1'use client';23import { Button } from '@/components/ui/button';4import { Input } from '@/components/ui/input';5import { Popover } from '@/components/ui/popover';67export function Default() {8 return (9 <Popover>10 <Popover.Trigger asChild>11 <Button variant="ghost">acme-web</Button>12 </Popover.Trigger>13 <Popover.Content aria-label="Rename project" className="w-80 max-w-[calc(100vw-2rem)]">14 <div className="grid gap-4">15 <div className="space-y-1">16 <h4 className="text-sm leading-none font-medium">Rename project</h4>17 <p className="text-muted-foreground text-xs">18 The old name keeps redirecting for 30 days.19 </p>20 </div>21 <Input22 id="project-slug"23 label="Project name"24 defaultValue="acme-web"25 description="Preview URL: acme-web.northwind.app"26 />27 <div className="flex justify-end gap-2">28 <Popover.Close asChild>29 <Button variant="outline" size="sm">30 Cancel31 </Button>32 </Popover.Close>33 <Popover.Close asChild>34 <Button size="sm">Rename</Button>35 </Popover.Close>36 </div>37 </div>38 </Popover.Content>39 </Popover>40 );41}
API Reference
Popover
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Initial open state. |
open | boolean | โ | Controlled open state. |
onOpenChange | (open: boolean) => void | โ | Callback when open state changes. |
Popover.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as child element. Props you set on the trigger, like className, aria-label or disabled, are merged into the child. |
Popover.Content
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Placement relative to trigger. |
sideOffset | number | 4 | Offset from the trigger. |
showClose | boolean | false | Shows a close button. |
closeLabel | string | "Close" | Accessible name of the close button. |
aria-label | string | โ | Name for the dialog. Without it (or aria-labelledby) the trigger names it. |
className | string | โ | Additional CSS classes. |
Popover.Close
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as child element. |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.