Hover Card
Vista previa enriquecida que se abre al pasar el cursor o enfocar un enlace o disparador. Retrasos, lados y alineación.
Nuevo1'use client';23import { Avatar } from '@/components/ui/avatar';4import { Button } from '@/components/ui/button';5import { HoverCard } from '@/components/ui/hover-card';6import { CalendarIcon } from '@/components/ui/calendar';78export function Default() {9 return (10 <HoverCard>11 <HoverCard.Trigger asChild>12 <Button variant="link">@figueroaignacio</Button>13 </HoverCard.Trigger>14 <HoverCard.Content className="w-72">15 <div className="flex gap-3">16 <Avatar>17 <Avatar.Image src="https://github.com/figueroaignacio.png" alt="Ignacio Figueroa" />18 <Avatar.Fallback>IF</Avatar.Fallback>19 </Avatar>20 <div className="flex flex-col gap-1">21 <p className="text-sm font-semibold">@figueroaignacio</p>22 <p className="text-muted-foreground text-xs">23 Building NachUI, a copy-paste component library with zero dependencies.24 </p>25 <p className="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">26 <CalendarIcon size={14} />27 Joined March 202428 </p>29 </div>30 </div>31 </HoverCard.Content>32 </HoverCard>33 );34}
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';1011const HOVER_CARD_STYLE = { willChange: 'opacity, transform, filter' } as const;1213function nextFocusableAfter(anchor: HTMLElement, skip: HTMLElement) {14 return getFocusable(document.body).find(15 (element) =>16 !skip.contains(element) &&17 !anchor.contains(element) &&18 Boolean(anchor.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING),19 );20}2122type HoverCardSide = 'top' | 'bottom' | 'left' | 'right';23type HoverCardAlign = 'start' | 'center' | 'end';2425interface HoverCardContextValue {26 open: boolean;27 id: string;28 scheduleOpen: () => void;29 scheduleClose: () => void;30 openNow: () => void;31 closeNow: () => void;32 toggle: () => void;33 isInside: (node: Node | null) => boolean;34 rootRef: React.RefObject<HTMLElement | null>;35 triggerRef: React.RefObject<HTMLElement | null>;36 contentRef: React.RefObject<HTMLDivElement | null>;37}3839const HoverCardContext = React.createContext<HoverCardContextValue | null>(null);4041function useHoverCardContext(): HoverCardContextValue {42 const context = React.use(HoverCardContext);43 if (!context) throw new Error('HoverCard parts must be used within HoverCard');44 return context;45}4647interface HoverCardProps {48 children: React.ReactNode;49 open?: boolean;50 defaultOpen?: boolean;51 onOpenChange?: (open: boolean) => void;52 openDelay?: number;53 closeDelay?: number;54}5556const HoverCardRoot = ({57 children,58 open: controlledOpen,59 defaultOpen = false,60 onOpenChange,61 openDelay = 500,62 closeDelay = 200,63}: HoverCardProps) => {64 const [open, setOpen] = useControllableState({65 value: controlledOpen,66 defaultValue: defaultOpen,67 onChange: onOpenChange,68 });69 const id = React.useId();70 const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);71 const rootRef = React.useRef<HTMLSpanElement>(null);72 const triggerRef = React.useRef<HTMLElement>(null);73 const contentRef = React.useRef<HTMLDivElement>(null);74 const layer = useLayer(open, false, contentRef);7576 const clearTimer = React.useCallback(() => {77 if (timer.current) clearTimeout(timer.current);78 timer.current = null;79 }, []);8081 const scheduleOpen = React.useCallback(() => {82 clearTimer();83 timer.current = setTimeout(() => setOpen(true), openDelay);84 }, [clearTimer, openDelay, setOpen]);8586 const scheduleClose = React.useCallback(() => {87 clearTimer();88 timer.current = setTimeout(() => setOpen(false), closeDelay);89 }, [clearTimer, closeDelay, setOpen]);9091 const openNow = React.useCallback(() => {92 clearTimer();93 setOpen(true);94 }, [clearTimer, setOpen]);9596 const closeNow = React.useCallback(() => {97 clearTimer();98 setOpen(false);99 }, [clearTimer, setOpen]);100101 const toggle = React.useCallback(() => {102 clearTimer();103 setOpen((current) => !current);104 }, [clearTimer, setOpen]);105106 const isInside = React.useCallback(107 (node: Node | null) =>108 Boolean(node && (rootRef.current?.contains(node) || contentRef.current?.contains(node))),109 [],110 );111112 React.useEffect(() => clearTimer, [clearTimer]);113114 React.useEffect(() => {115 if (!open) return;116 const handleEscape = (event: KeyboardEvent) => {117 if (event.key !== 'Escape' || event.defaultPrevented || !layer.isTopmost()) return;118 event.preventDefault();119 closeNow();120 };121 const handlePointerDown = (event: PointerEvent) => {122 const target = event.target as Node | null;123 if (isInside(target) || layer.isInsideUpperLayer(target)) return;124 closeNow();125 };126 document.addEventListener('keydown', handleEscape);127 document.addEventListener('pointerdown', handlePointerDown);128 return () => {129 document.removeEventListener('keydown', handleEscape);130 document.removeEventListener('pointerdown', handlePointerDown);131 };132 }, [open, closeNow, isInside, layer]);133134 const context = React.useMemo(135 () => ({136 open,137 id,138 scheduleOpen,139 scheduleClose,140 openNow,141 closeNow,142 toggle,143 isInside,144 rootRef,145 triggerRef,146 contentRef,147 }),148 [open, id, scheduleOpen, scheduleClose, openNow, closeNow, toggle, isInside],149 );150151 return (152 <HoverCardContext value={context}>153 <span ref={rootRef} className="relative inline-flex">154 {children}155 </span>156 </HoverCardContext>157 );158};159HoverCardRoot.displayName = 'HoverCard';160161interface HoverCardTriggerProps extends React.HTMLAttributes<HTMLElement> {162 asChild?: boolean;163}164165type TriggerChildProps = {166 onPointerEnter?: (event: React.PointerEvent<HTMLElement>) => void;167 onPointerLeave?: (event: React.PointerEvent<HTMLElement>) => void;168 onPointerDown?: (event: React.PointerEvent<HTMLElement>) => void;169 onClick?: (event: React.MouseEvent<HTMLElement>) => void;170 onFocus?: (event: React.FocusEvent<HTMLElement>) => void;171 onBlur?: (event: React.FocusEvent<HTMLElement>) => void;172 onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;173 className?: string;174 tabIndex?: number;175 ref?: React.Ref<HTMLElement>;176};177178const HoverCardTrigger = ({179 asChild = false,180 children,181 className,182 onPointerEnter,183 onPointerLeave,184 onFocus,185 onBlur,186 onPointerDown,187 onClick,188 onKeyDown,189 ref,190 ...props191}: HoverCardTriggerProps & { ref?: React.Ref<HTMLElement> }) => {192 const {193 open,194 id,195 scheduleOpen,196 scheduleClose,197 openNow,198 toggle,199 isInside,200 triggerRef,201 contentRef,202 } = useHoverCardContext();203 const lastPointerType = React.useRef<string | null>(null);204 const childRef = React.isValidElement(children)205 ? (children.props as TriggerChildProps).ref206 : undefined;207208 const mergedRef = React.useCallback(209 (node: HTMLElement | null) => {210 triggerRef.current = node;211 assignRef(ref, node);212 assignRef(childRef, node);213 },214 [ref, childRef, triggerRef],215 );216217 const handlers = {218 onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {219 onPointerEnter?.(event);220 if (event.pointerType !== 'touch') scheduleOpen();221 },222 onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {223 onPointerLeave?.(event);224 if (event.pointerType !== 'touch') scheduleClose();225 },226 onPointerDown: (event: React.PointerEvent<HTMLElement>) => {227 onPointerDown?.(event);228 lastPointerType.current = event.pointerType;229 },230 onClick: (event: React.MouseEvent<HTMLElement>) => {231 onClick?.(event);232 if (lastPointerType.current === 'touch') toggle();233 lastPointerType.current = null;234 },235 onFocus: (event: React.FocusEvent<HTMLElement>) => {236 onFocus?.(event);237 if (lastPointerType.current !== 'touch') openNow();238 },239 onBlur: (event: React.FocusEvent<HTMLElement>) => {240 onBlur?.(event);241 if (isInside(event.relatedTarget as Node | null)) return;242 scheduleClose();243 },244 onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {245 onKeyDown?.(event);246 if (event.defaultPrevented || event.key !== 'Tab' || event.shiftKey || !open) return;247 const first = contentRef.current ? getFocusable(contentRef.current)[0] : undefined;248 if (!first) return;249 event.preventDefault();250 first.focus();251 },252 };253254 const shared = {255 'data-slot': 'hover-card-trigger',256 'data-state': open ? 'open' : 'closed',257 'aria-describedby': open ? id : undefined,258 };259260 if (asChild && React.isValidElement(children)) {261 const child = children as React.ReactElement<Record<string, unknown>>;262 const childProps = child.props as TriggerChildProps;263264 return React.cloneElement(child, {265 ...props,266 ...shared,267 ref: mergedRef,268 tabIndex: childProps.tabIndex ?? props.tabIndex ?? 0,269 className: cn(className, childProps.className),270 onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {271 childProps.onPointerEnter?.(event);272 handlers.onPointerEnter(event);273 },274 onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {275 childProps.onPointerLeave?.(event);276 handlers.onPointerLeave(event);277 },278 onPointerDown: (event: React.PointerEvent<HTMLElement>) => {279 childProps.onPointerDown?.(event);280 handlers.onPointerDown(event);281 },282 onClick: (event: React.MouseEvent<HTMLElement>) => {283 childProps.onClick?.(event);284 handlers.onClick(event);285 },286 onFocus: (event: React.FocusEvent<HTMLElement>) => {287 childProps.onFocus?.(event);288 handlers.onFocus(event);289 },290 onBlur: (event: React.FocusEvent<HTMLElement>) => {291 childProps.onBlur?.(event);292 handlers.onBlur(event);293 },294 onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {295 childProps.onKeyDown?.(event);296 handlers.onKeyDown(event);297 },298 });299 }300301 return (302 <span303 ref={mergedRef as React.Ref<HTMLSpanElement>}304 tabIndex={0}305 className={cn('inline-flex cursor-default', className)}306 {...shared}307 {...handlers}308 {...props}309 >310 {children}311 </span>312 );313};314HoverCardTrigger.displayName = 'HoverCardTrigger';315316interface HoverCardContentProps extends HTMLMotionProps<'div'> {317 side?: HoverCardSide;318 align?: HoverCardAlign;319 sideOffset?: number;320 children?: React.ReactNode;321}322323const HoverCardPanel = ({324 side: preferredSide = 'bottom',325 align = 'center',326 sideOffset = 4,327 className,328 children,329 style,330 onPointerEnter,331 onPointerLeave,332 onFocus,333 onBlur,334 onKeyDown,335 ...props336}: HoverCardContentProps) => {337 const { id, openNow, scheduleClose, isInside, triggerRef, contentRef } = useHoverCardContext();338 const shouldReduceMotion = useReducedMotion();339 const floating = useFloating({340 open: true,341 anchorRef: triggerRef,342 floatingRef: contentRef,343 side: preferredSide,344 align,345 sideOffset,346 });347 const side = floating.side;348349 const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {350 onKeyDown?.(event);351 if (event.defaultPrevented || event.key !== 'Tab') return;352 const content = contentRef.current;353 const trigger = triggerRef.current;354 if (!content || !trigger) return;355 const focusable = getFocusable(content);356 const active = document.activeElement;357358 if (event.shiftKey) {359 if (active === focusable[0]) {360 event.preventDefault();361 trigger.focus();362 }363 return;364 }365366 if (active === focusable[focusable.length - 1]) {367 const next = nextFocusableAfter(trigger, content);368 if (!next) return;369 event.preventDefault();370 next.focus();371 }372 };373374 return (375 <motion.div376 ref={contentRef}377 id={id}378 data-slot="hover-card-content"379 data-side={side}380 data-align={align}381 variants={shouldReduceMotion ? reveal : floatingVariants[side]}382 initial="hidden"383 animate="visible"384 exit="exit"385 style={{ ...HOVER_CARD_STYLE, ...style, ...floating.style }}386 onPointerEnter={(event) => {387 onPointerEnter?.(event);388 if (event.pointerType !== 'touch') openNow();389 }}390 onPointerLeave={(event) => {391 onPointerLeave?.(event);392 if (event.pointerType !== 'touch') scheduleClose();393 }}394 onFocus={(event) => {395 onFocus?.(event);396 openNow();397 }}398 onBlur={(event) => {399 onBlur?.(event);400 if (isInside(event.relatedTarget as Node | null)) return;401 scheduleClose();402 }}403 onKeyDown={handleKeyDown}404 className={cn(405 'bg-popover text-popover-foreground border-border z-500 max-h-(--available-height) w-64 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-md border p-4 shadow-md outline-none',406 floatingOrigin[side],407 className,408 )}409 {...props}410 >411 {children}412 </motion.div>413 );414};415416const HoverCardContent = (props: HoverCardContentProps) => {417 const { open } = useHoverCardContext();418419 return (420 <FloatingPortal>421 <AnimatePresence>{open && <HoverCardPanel {...props} />}</AnimatePresence>422 </FloatingPortal>423 );424};425HoverCardContent.displayName = 'HoverCardContent';426427const HoverCard = Object.assign(HoverCardRoot, {428 Trigger: HoverCardTrigger,429 Content: HoverCardContent,430});431432export { HoverCard, useHoverCardContext };433export type {434 HoverCardProps,435 HoverCardTriggerProps,436 HoverCardContentProps,437 HoverCardSide,438 HoverCardAlign,439};
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { HoverCard } from '@/components/ui/hover-card';
1<HoverCard>2 <HoverCard.Trigger asChild>3 <a href="/u/nacho">@nacho</a>4 </HoverCard.Trigger>5 <HoverCard.Content>Vista previa del perfil</HoverCard.Content>6</HoverCard>
La tarjeta se abre después de
openDelay cuando el puntero entra al disparador y se cierra después de closeDelay cuando sale, salvo que se haya movido sobre la tarjeta. El foco de teclado la abre de inmediato y el blur la cierra, y Tab pasa por los enlaces de la tarjeta antes de seguir con la página. Escape la cierra sin cerrar el drawer o dialog que la contiene. En pantallas táctiles un toque en el disparador abre o cierra la tarjeta y un toque en cualquier otro lado la cierra; el click propio del disparador se sigue ejecutando, así que un enlace igual navega.La tarjeta se renderiza en un portal, así que un
overflow: hidden en un padre nunca la recorta, y se da vuelta al otro lado cuando no hay lugar.Composición
HoverCard├── HoverCard.Trigger└── HoverCard.Content
Variantes
Predeterminado (Default)
Una tarjeta de perfil sobre una mención.
1'use client';23import { Avatar } from '@/components/ui/avatar';4import { Button } from '@/components/ui/button';5import { HoverCard } from '@/components/ui/hover-card';6import { CalendarIcon } from '@/components/ui/calendar';78export function Default() {9 return (10 <HoverCard>11 <HoverCard.Trigger asChild>12 <Button variant="link">@figueroaignacio</Button>13 </HoverCard.Trigger>14 <HoverCard.Content className="w-72">15 <div className="flex gap-3">16 <Avatar>17 <Avatar.Image src="https://github.com/figueroaignacio.png" alt="Ignacio Figueroa" />18 <Avatar.Fallback>IF</Avatar.Fallback>19 </Avatar>20 <div className="flex flex-col gap-1">21 <p className="text-sm font-semibold">@figueroaignacio</p>22 <p className="text-muted-foreground text-xs">23 Building NachUI, a copy-paste component library with zero dependencies.24 </p>25 <p className="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">26 <CalendarIcon size={14} />27 Joined March 202428 </p>29 </div>30 </div>31 </HoverCard.Content>32 </HoverCard>33 );34}
Vista previa de enlace
En línea dentro de un párrafo, abriéndose arriba y alineada al inicio del enlace.
The incident started after the v2.24.0 release rolled out to the EU region, and was resolved by rolling back the pooling change.
1'use client';23import { HoverCard } from '@/components/ui/hover-card';45export function LinkPreview() {6 return (7 <p className="text-muted-foreground max-w-sm text-sm leading-relaxed">8 The incident started after the{' '}9 <HoverCard openDelay={200}>10 <HoverCard.Trigger asChild>11 <a12 href="#release-v2-24-0"13 className="text-foreground font-medium underline underline-offset-4"14 >15 v2.24.0 release16 </a>17 </HoverCard.Trigger>18 <HoverCard.Content side="top" align="start" className="w-64 p-0">19 <div className="bg-muted h-24 rounded-t-md" />20 <div className="flex flex-col gap-1 p-3">21 <p className="text-sm font-medium">Release v2.24.0</p>22 <p className="text-muted-foreground text-xs">23 Connection pooling for the EU region, new webhook retries, and 14 fixes.24 </p>25 <p className="text-muted-foreground text-xs">github.com/northwind/api</p>26 </div>27 </HoverCard.Content>28 </HoverCard>{' '}29 rolled out to the EU region, and was resolved by rolling back the pooling change.30 </p>31 );32}
Referencia de API
HoverCard
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
open | boolean | - | Estado abierto controlado |
defaultOpen | boolean | false | Estado inicial cuando no es controlado |
onOpenChange | (open: boolean) => void | - | Se llama cuando cambia el estado |
openDelay | number | 500 | Milisegundos de hover antes de abrir |
closeDelay | number | 200 | Milisegundos después de salir antes de cerrar |
HoverCard.Trigger
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
asChild | boolean | false | Adjunta los handlers al hijo en lugar de un span |
HoverCard.Content
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Posición relativa al disparador |
align | "start" | "center" | "end" | "center" | Alineación a lo largo de ese lado |
sideOffset | number | 4 | Separación del disparador en píxeles |
className | string | - | Clases CSS adicionales |
¿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.