HextaUI

Hover card

Una tarjeta de vista previa que se abre al pasar el cursor o enfocar un enlace, para contenido que los usuarios videntes pueden ojear.

Shipped by @mira, reviewed by @jun and tested with a screen reader by @sol.

"use client"

import { IconMapPin } from "@tabler/icons-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
  createHoverCardHandle,
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

type Person = {
  handle: string
  name: string
  initials: string
  bio: string
  location: string
}

const people: Record<string, Person> = {
  mira: {
    handle: "mira",
    name: "Mira Okafor",
    initials: "MO",
    bio: "Design engineer. Obsessed with easing curves.",
    location: "Lagos",
  },
  jun: {
    handle: "jun",
    name: "Jun Park",
    initials: "JP",
    bio: "Maintains the motion tokens and keeps the docs honest about what ships.",
    location: "Seoul",
  },
  sol: {
    handle: "sol",
    name: "Sol Ferreira",
    initials: "SF",
    bio: "Accessibility.",
    location: "Lisbon",
  },
}

const profile = createHoverCardHandle<Person>()

function Mention({ person }: { person: Person }) {
  return (
    <HoverCardTrigger
      handle={profile}
      payload={person}
      href="#"
      delay={250}
      render={
        <a className="font-medium text-foreground underline decoration-border underline-offset-4 hover:decoration-foreground" />
      }
    >
      @{person.handle}
    </HoverCardTrigger>
  )
}

export function HoverCardDemo() {
  return (
    <>
      <p className="max-w-sm text-center text-sm/relaxed text-muted-foreground">
        Shipped by <Mention person={people.mira} />, reviewed by{" "}
        <Mention person={people.jun} /> and tested with a screen reader by{" "}
        <Mention person={people.sol} />.
      </p>
      <HoverCard handle={profile}>
        {({ payload }) => (
          <HoverCardContent arrow>
            {payload && (
              <div className="flex gap-3">
                <Avatar>
                  <AvatarFallback>{payload.initials}</AvatarFallback>
                </Avatar>
                <div className="flex min-w-0 flex-col gap-1">
                  <p className="font-medium">{payload.name}</p>
                  <p className="text-muted-foreground">{payload.bio}</p>
                  <p className="flex items-center gap-1 pt-1 text-xs text-muted-foreground">
                    <IconMapPin className="size-3.5" aria-hidden="true" />
                    {payload.location}
                  </p>
                </div>
              </div>
            )}
          </HoverCardContent>
        )}
      </HoverCard>
    </>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/hover-card.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"
<HoverCard>
  <HoverCardTrigger href="/profile" render={<Button variant="link" nativeButton={false} render={<a />} />}>
    @hextaui
  </HoverCardTrigger>
  <HoverCardContent>
    Components built on shadcn/ui.
  </HoverCardContent>
</HoverCard>

Una hover card es una vista previa, no un menú ni un diálogo. El trigger sigue siendo un enlace normal, así que todo lo que hay en la tarjeta también debe estar en la página a la que enlaza.

HoverCard
├── HoverCardTrigger
└── HoverCardContent

Lado

Define side y align en <HoverCardContent />. Los lados lógicos como inline-end siguen la dirección de lectura, y la tarjeta se voltea o desplaza cuando saldría de la pantalla.

import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

const sides = ["top", "inline-end", "bottom", "inline-start"] as const

export function HoverCardSides() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {sides.map((side) => (
        <HoverCard key={side}>
          <HoverCardTrigger
            href="#"
            delay={200}
            render={
              <Button variant="outline" nativeButton={false} render={<a />} />
            }
          >
            {side}
          </HoverCardTrigger>
          <HoverCardContent side={side} className="w-48">
            Opens on the {side} side, and flips when there isn’t room.
          </HoverCardContent>
        </HoverCard>
      ))}
    </div>
  )
}

Retraso

delay y closeDelay en el trigger definen cuánto debe reposar el puntero antes de que se abra la tarjeta y cuánto permanece tras salir. El valor por defecto de 600ms evita que las tarjetas se abran de golpe cuando el puntero cruza una página.

import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardDelay() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      <HoverCard>
        <HoverCardTrigger
          href="#"
          render={
            <Button variant="outline" nativeButton={false} render={<a />} />
          }
        >
          Default (600ms)
        </HoverCardTrigger>
        <HoverCardContent className="w-56">
          Waits long enough that passing the pointer over the link doesn’t open
          it.
        </HoverCardContent>
      </HoverCard>
      <HoverCard>
        <HoverCardTrigger
          href="#"
          delay={150}
          closeDelay={100}
          render={
            <Button variant="outline" nativeButton={false} render={<a />} />
          }
        >
          Fast (150ms)
        </HoverCardTrigger>
        <HoverCardContent className="w-56">
          Opens almost right away and closes quickly.
        </HoverCardContent>
      </HoverCard>
    </div>
  )
}

Usa render para convertir el trigger en cualquier enlace, incluido uno dentro de una frase. Cuando un enlace se parte en dos líneas, la tarjeta se ancla a la línea sobre la que pasaste el cursor.

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardInline() {
  return (
    <p className="max-w-sm text-sm text-muted-foreground">
      Built on{" "}
      <HoverCard>
        <HoverCardTrigger
          href="https://base-ui.com"
          render={
            <a className="font-medium text-foreground underline decoration-border underline-offset-4 hover:decoration-foreground" />
          }
        >
          Base UI
        </HoverCardTrigger>
        <HoverCardContent className="w-60">
          Unstyled, accessible React primitives from the creators of Radix,
          Floating UI and Material UI.
        </HoverCardContent>
      </HoverCard>{" "}
      primitives, styled with Tailwind CSS and theme tokens, and ready to copy
      into your project.
    </p>
  )
}

Contenido interactivo

Mueve el puntero del enlace a la tarjeta y esta permanece abierta, así que se puede hacer clic en los enlaces y botones de su interior. El camino entre ambos es tolerante, de modo que un movimiento diagonal no la cierra.

import { IconExternalLink, IconStar } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardRichContent() {
  return (
    <HoverCard>
      <HoverCardTrigger
        href="https://github.com/preetsuthar17"
        render={<Button variant="link" nativeButton={false} render={<a />} />}
      >
        hextaui/components
      </HoverCardTrigger>
      <HoverCardContent className="w-72">
        <div className="flex flex-col gap-2">
          <p className="font-medium">hextaui/components</p>
          <p className="text-muted-foreground">
            Copy-paste React components with motion, keyboard support and RTL
            built in.
          </p>
          <div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
            <span className="flex items-center gap-1">
              <IconStar className="size-3.5" aria-hidden="true" />
              2.4k
            </span>
            <a
              href="https://github.com/preetsuthar17"
              className="flex items-center gap-1 text-foreground underline-offset-4 hover:underline"
            >
              Open on GitHub
              <IconExternalLink className="size-3.5" aria-hidden="true" />
            </a>
          </div>
        </div>
      </HoverCardContent>
    </HoverCard>
  )
}

Tarjeta compartida

Una tarjeta sirve a muchos enlaces. Crea un handle con createHoverCardHandle, dale a cada trigger un payload y léelo en la tarjeta. Al moverte entre nombres, la tarjeta se desliza hasta el nuevo enlace en lugar de cerrarse y reabrirse. El contenido anterior sale deslizándose en la dirección en que te moviste, el nuevo entra y la altura se ajusta suavemente entre ambos.

"use client"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import {
  createHoverCardHandle,
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

type Person = { name: string; initials: string; role: string }

const team: Person[] = [
  { name: "Ada Lovelace", initials: "AL", role: "Analyst" },
  { name: "Alan Turing", initials: "AT", role: "Research" },
  { name: "Grace Hopper", initials: "GH", role: "Compilers" },
]

const profileCard = createHoverCardHandle<Person>()

export function HoverCardDetached() {
  return (
    <div className="flex flex-col items-center gap-3">
      <ul className="flex flex-col items-start gap-2 text-sm">
        {team.map((person) => (
          <li key={person.name}>
            <HoverCardTrigger
              handle={profileCard}
              payload={person}
              href="#"
              delay={300}
              render={
                <a className="font-medium underline decoration-border underline-offset-4 hover:decoration-foreground" />
              }
            >
              {person.name}
            </HoverCardTrigger>
          </li>
        ))}
      </ul>
      <HoverCard handle={profileCard}>
        {({ payload }) => (
          <HoverCardContent side="inline-end" align="start" className="w-56">
            {payload ? (
              <div className="flex items-center gap-3">
                <Avatar>
                  <AvatarFallback>{payload.initials}</AvatarFallback>
                </Avatar>
                <div className="flex min-w-0 flex-col">
                  <p className="font-medium">{payload.name}</p>
                  <p className="text-muted-foreground">{payload.role}</p>
                </div>
              </div>
            ) : null}
          </HoverCardContent>
        )}
      </HoverCard>
    </div>
  )
}

Flecha

arrow añade una flecha que se une al borde de la tarjeta sin costura. El desplazamiento lateral crece para dejarle sitio, y sigue a la tarjeta cuando se voltea.

import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardArrowDemo() {
  return (
    <HoverCard>
      <HoverCardTrigger
        href="#"
        delay={200}
        render={
          <Button variant="outline" nativeButton={false} render={<a />} />
        }
      >
        Release notes
      </HoverCardTrigger>
      <HoverCardContent arrow side="top" className="w-56">
        Version 2.4 adds shared hover cards and smoother text areas.
      </HoverCardContent>
    </HoverCard>
  )
}

Contenido en carga

Empieza a obtener los datos en onOpenChange y muestra un skeleton hasta que lleguen. Cuando el contenido cambia, la tarjeta se ajusta suavemente a su nueva altura en lugar de saltar.

"use client"

import * as React from "react"

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"
import { Skeleton, SkeletonText } from "@/components/ui/skeleton"

type Repo = { name: string; description: string; stars: number }

function fetchRepo(): Promise<Repo> {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          name: "hextaui/hextaui",
          description:
            "Components built on shadcn/ui and Base UI, with motion, keyboard support and edge cases handled. Copy them into your project and make them yours.",
          stars: 2140,
        }),
      900
    )
  )
}

export function HoverCardAsync() {
  const [repo, setRepo] = React.useState<Repo | null>(null)
  const request = React.useRef<Promise<void> | null>(null)

  return (
    <HoverCard
      onOpenChange={(open) => {
        if (open && !request.current) {
          request.current = fetchRepo().then(setRepo)
        }
      }}
    >
      <HoverCardTrigger
        href="#"
        delay={200}
        render={
          <a className="text-sm font-medium underline decoration-border underline-offset-4 hover:decoration-foreground" />
        }
      >
        hextaui/hextaui
      </HoverCardTrigger>
      <HoverCardContent className="w-72" aria-busy={!repo}>
        {repo ? (
          <div className="flex flex-col gap-1.5">
            <p className="font-medium">{repo.name}</p>
            <p className="text-muted-foreground">{repo.description}</p>
            <p className="text-xs text-muted-foreground">
              {repo.stars.toLocaleString("en-US")} stars
            </p>
          </div>
        ) : (
          <div className="flex flex-col gap-2">
            <Skeleton className="h-4 w-32" />
            <SkeletonText lines={2} />
          </div>
        )}
      </HoverCardContent>
    </HoverCard>
  )
}

Controlado

Pasa open y onOpenChange. El segundo argumento indica por qué cambió, como trigger-hover, trigger-focus o escape-key.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardControlled() {
  const [open, setOpen] = React.useState(false)
  const [reason, setReason] = React.useState("none")

  return (
    <div className="flex flex-col items-center gap-3">
      <HoverCard
        open={open}
        onOpenChange={(next, details) => {
          setOpen(next)
          setReason(details.reason)
        }}
      >
        <HoverCardTrigger
          href="#"
          render={<Button variant="link" nativeButton={false} render={<a />} />}
        >
          Release notes
        </HoverCardTrigger>
        <HoverCardContent className="w-60">
          Version 2.0 rebuilds every component on Base UI.
        </HoverCardContent>
      </HoverCard>
      <p className="text-sm text-muted-foreground">
        Open: {String(open)} · last reason: {reason}
      </p>
    </div>
  )
}

Contenido largo

El texto sin espacios se ajusta dentro de la tarjeta, y una tarjeta más alta que el espacio junto al trigger se desplaza en lugar de salirse de la pantalla.

import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardLongContent() {
  return (
    <HoverCard>
      <HoverCardTrigger
        href="#"
        render={<Button variant="link" nativeButton={false} render={<a />} />}
      >
        Long preview
      </HoverCardTrigger>
      <HoverCardContent>
        <p className="font-medium">
          https://example.com/a/really/long/url/without/any/spaces/at/all
        </p>
        <p className="text-muted-foreground">
          Long previews wrap inside the card, and when the card is taller than
          the space around the trigger it scrolls instead of leaving the screen.
          Keep previews short, though: everything here should also be on the
          linked page.
        </p>
      </HoverCardContent>
    </HoverCard>
  )
}

De derecha a izquierda

La tarjeta lee la dirección del trigger, así que los lados lógicos y la alineación se invierten y la animación de escala crece desde la esquina correcta.

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function HoverCardRtl() {
  return (
    <div dir="rtl">
      <HoverCard>
        <HoverCardTrigger
          href="#"
          render={<Button variant="link" nativeButton={false} render={<a />} />}
        >
          <span dir="ltr">@hextaui</span>
        </HoverCardTrigger>
        <HoverCardContent side="inline-end">
          <div className="flex gap-3">
            <Avatar>
              <AvatarFallback>هـ</AvatarFallback>
            </Avatar>
            <div className="flex min-w-0 flex-col gap-1">
              <p className="font-medium">هكستا</p>
              <p className="text-muted-foreground">
                مكونات مبنية على shadcn/ui مع حركة سلسة ودعم كامل للوحة
                المفاتيح.
              </p>
            </div>
          </div>
        </HoverCardContent>
      </HoverCard>
    </div>
  )
}
KeyAcción
TabDar foco al trigger abre la tarjeta tras el mismo retraso que al pasar el cursor. Mover el foco a otro lugar la cierra.
EnterSigue el enlace, como cualquier otro enlace.
EscCierra la tarjeta.
  • La tarjeta es un extra visual para usuarios videntes de ratón y teclado. Los lectores de pantalla solo oyen el enlace, así que no se les obliga a pasar por una vista previa en cada enlace.
  • No se abre nada en pantallas táctiles, donde no hay hover. Un toque sigue el enlace, y por eso el destino debe contener la misma información.
  • El foco nunca entra en la tarjeta. Si necesita controles que deban ser accesibles con el teclado, usa un popover en su lugar.
  • Con movimiento reducido, la tarjeta se desvanece sin escalar, y una tarjeta compartida salta entre enlaces en lugar de deslizarse.

Construido sobre la preview card de Base UI. Cada parte acepta las props de la primitiva que envuelve.

PropTipoPredeterminado
open
boolean–
defaultOpen
booleanfalse
onOpenChangedetails.reason es trigger-hover, trigger-focus, trigger-press, outside-press, escape-key, imperative-action o none.
(open: boolean, details) => void–
onOpenChangeCompleteSe llama cuando termina la animación de apertura o cierre.
(open: boolean) => void–
handleConecta triggers renderizados fuera de la raíz.
HoverCardHandle<Payload>–
childrenUsa la forma de función para leer el payload del trigger que abrió la tarjeta.
ReactNode | ({ payload }) => ReactNode–
actionsRef
RefObject<{ close, unmount }>–
PropTipoPredeterminado
href
string–
delayMilisegundos antes de que pasar el cursor o recibir foco abra la tarjeta.
number600
closeDelayMilisegundos que la tarjeta permanece abierta tras salir.
number300
handle
HoverCardHandle<Payload>–
payloadSe pasa a la tarjeta cuando este trigger la abre.
Payload–
renderRenderiza tu propio enlace, como <Button variant="link" /> o un enlace del router.
ReactElement | (props, state) => ReactElement<a>
AtributoDescripción
data-slot="hover-card-trigger"Apunta al trigger en CSS.
data-popup-openPresente mientras está abierta la tarjeta de este trigger.
PropTipoPredeterminado
side
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""center"
arrowMuestra una flecha que apunta al trigger.
booleanfalse
sideOffset
number | OffsetFunction6, or 10 with arrow
alignOffset
number | OffsetFunction0
collisionPaddingEspacio que se mantiene entre la tarjeta y el borde del viewport.
number | Rect8
collisionAvoidanceSi la tarjeta se voltea, se desplaza o no hace nada en una colisión.
CollisionAvoidance–
sticky
booleanfalse
anchorPosiciona respecto a algo distinto del trigger.
Element | RefObject | VirtualElement–
positionMethod
"absolute" | "fixed""absolute"
disableAnchorTracking
booleanfalse
portalPropsProps para el portal, como container.
HoverCardPortalProps–
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescripción
data-slot="hover-card-content"Apunta a la tarjeta en CSS.
data-openPresente mientras la tarjeta está abierta.
data-starting-stylePresente mientras la tarjeta se anima al abrirse.
data-ending-stylePresente mientras la tarjeta se anima al cerrarse.
data-instantfocus cuando el foco del teclado abrió la tarjeta, dismiss cuando Escape o una pulsación exterior la cerró. La animación de salida se omite mientras está definido.
data-sideEl lado en el que se asentó la tarjeta tras las colisiones.
data-alignLa alineación en la que se asentó.
--transform-originDesde dónde crece la animación de escala, junto al trigger.
--available-widthEspacio que queda junto al trigger. La tarjeta nunca crece más allá.
--available-heightEspacio que queda arriba o abajo. El contenido más alto se desplaza.
Atributo del posicionadorDescripción
data-slot="hover-card-positioner"El elemento que se mueve. Se desliza cuando una tarjeta compartida cambia de enlace.
data-anchor-hiddenPresente cuando el trigger sale de la vista al desplazarse.
Partes internasDescripción
data-slot="hover-card-viewport"Envuelve el contenido. Lleva data-activation-direction mientras una tarjeta compartida cambia de enlace.
data-slot="hover-card-body"Tu contenido. Su altura se ajusta suavemente cuando cambia.
data-slot="hover-card-arrow"La flecha, con data-side para su borde.
--popup-heightSe define en la tarjeta mientras cambia de tamaño entre enlaces.
PropTipoPredeterminado
container
HTMLElement | ShadowRoot | RefObject | nulldocument.body
keepMounted
booleanfalse

Devuelve un handle para triggers separados. Sus métodos open(triggerId) y close() controlan la tarjeta desde manejadores de eventos, y isOpen lee su estado. Pasa un argumento de tipo para tipar el payload.

Usado en bloques

Bloques que se construyen sobre Hover card.