Popover
Detalles contextuales a demanda. UI no bloqueante. Mantener contexto.
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}
Instalación
Copia y pega el siguiente código en tu proyecto.
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 };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { Popover } from '@/components/ui/popover';
1<Popover>2 <Popover.Trigger>Abrir</Popover.Trigger>3 <Popover.Content>4 <p>Contenido interactivo acá.</p>5 </Popover.Content>6</Popover>
El contenido se renderiza en un portal sobre
document.body, así que un overflow: hidden en un padre nunca lo recorta. Si no hay lugar se da vuelta al otro lado y se queda dentro del viewport mientras la página scrollea.Al abrirse, el foco entra al contenido.
Escape, Popover.Close y el botón de cierre integrado lo cierran y devuelven el foco al trigger; un click afuera lo cierra y deja el foco donde hiciste click. Tab recorre el contenido y después sigue con lo que viene después del trigger en la página, y Shift+Tab desde el primer control vuelve al trigger.El contenido es un
dialog que toma el nombre de su trigger. Pasale aria-label o aria-labelledby a Popover.Content cuando el texto del trigger no alcanza para describirlo, por ejemplo con un botón de ícono.Composición
Usa la siguiente composición para construir un
Popover:Popover├── Popover.Trigger└── Popover.Content└── Popover.Close
Ejemplos
Predeterminado (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}
Referencia de API
Popover
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
defaultOpen | boolean | false | Estado inicial de apertura. |
open | boolean | — | Estado controlado. |
onOpenChange | (open: boolean) => void | — | Callback cuando cambia el estado de apertura. |
Popover.Trigger
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
asChild | boolean | false | Renderizar como hijo. Las props que pongas en el trigger, como className, aria-label o disabled, se combinan con las del hijo. |
Popover.Content
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Posición relativa al trigger. |
sideOffset | number | 4 | Distancia desde el trigger. |
showClose | boolean | false | Muestra un botón de cerrar. |
closeLabel | string | "Close" | Nombre accesible del botón de cierre. |
aria-label | string | — | Nombre del dialog. Sin esto (ni aria-labelledby) lo nombra el trigger. |
className | string | — | Clases CSS adicionales. |
Popover.Close
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
asChild | boolean | false | Renderizar como hijo. |
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.