HextaUI

Hover card

Um card de prévia que abre quando um link recebe hover ou foco, para conteúdo que usuários que enxergam podem conferir rapidamente.

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

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele 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>

Um hover card é uma prévia, não um menu nem um diálogo. O gatilho continua sendo um link normal, então tudo o que está no card também deve estar na página para a qual ele aponta.

HoverCard
├── HoverCardTrigger
└── HoverCardContent

Lado

Defina side e align em <HoverCardContent />. Lados lógicos como inline-end seguem a direção de leitura, e o card se inverte ou se desloca quando sairia da tela.

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>
  )
}

Atraso

delay e closeDelay no gatilho definem por quanto tempo o ponteiro deve parar antes de o card abrir e quanto ele permanece depois de o ponteiro sair. O padrão de 600ms evita que os cards pisquem abertos quando o ponteiro atravessa uma 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>
  )
}

Use render para fazer do gatilho qualquer link, inclusive um dentro de uma frase. Quando um link quebra em duas linhas, o card se ancora na linha sobre a qual você passou o mouse.

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>
  )
}

Conteúdo interativo

Mova o ponteiro do link para o card e ele permanece aberto, para que links e botões dentro dele possam ser clicados. O caminho entre eles é tolerante, então um movimento diagonal não o fecha.

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>
  )
}

Card compartilhado

Um card atende vários links. Crie um handle com createHoverCardHandle, dê a cada gatilho um payload e leia-o no card. Mover-se entre nomes faz o card deslizar até o novo link em vez de fechar e reabrir. O conteúdo antigo sai deslizando no sentido do seu movimento, o novo entra deslizando, e a altura se ajusta suavemente entre os dois.

"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>
  )
}

Seta

arrow adiciona uma seta que se une à borda do card sem emenda. O deslocamento lateral aumenta para abrir espaço para ela, e ela acompanha o card quando ele se inverte.

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>
  )
}

Carregando conteúdo

Comece a buscar em onOpenChange e mostre um skeleton até os dados chegarem. Quando o conteúdo muda, o card se ajusta suavemente à nova altura em vez 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

Passe open e onOpenChange. O segundo argumento diz por que mudou, como trigger-hover, trigger-focus ou 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>
  )
}

Conteúdo longo

Texto sem quebra é quebrado dentro do card, e um card mais alto que o espaço ao lado do gatilho rola em vez de sair da tela.

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>
  )
}

Da direita para a esquerda

O card lê a direção do gatilho, então os lados lógicos e o alinhamento se invertem e a animação de escala cresce a partir do canto correto.

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>
  )
}
TeclaAção
TabFocar o gatilho abre o card após o mesmo atraso do hover. Mover o foco adiante o fecha.
EnterSegue o link, como qualquer outro link.
EscFecha o card.
  • O card é um extra visual para usuários videntes de mouse e teclado. Os leitores de tela ouvem apenas o link, para não serem forçados a passar por uma prévia em cada link.
  • Nada abre em telas sensíveis ao toque, onde não há hover. Um toque segue o link, e por isso o destino precisa conter as mesmas informações.
  • O foco nunca entra no card. Se ele precisar de controles alcançáveis pelo teclado, use um popover.
  • Com movimento reduzido ativado, o card aparece com fade, sem escala, e um card compartilhado salta entre os links em vez de deslizar.

Construído sobre o preview card do Base UI. Cada parte aceita as props da primitiva que envolve.

PropTipoPadrão
open
boolean–
defaultOpen
booleanfalse
onOpenChangedetails.reason é trigger-hover, trigger-focus, trigger-press, outside-press, escape-key, imperative-action ou none.
(open: boolean, details) => void–
onOpenChangeCompleteChamado após o fim da animação de abertura ou fechamento.
(open: boolean) => void–
handleConecta gatilhos renderizados fora da raiz.
HoverCardHandle<Payload>–
childrenUse a forma de função para ler o payload do gatilho que abriu o card.
ReactNode | ({ payload }) => ReactNode–
actionsRef
RefObject<{ close, unmount }>–
PropTipoPadrão
href
string–
delayMilissegundos antes de o hover ou o foco abrir o card.
number600
closeDelayMilissegundos que o card permanece aberto depois de o ponteiro sair.
number300
handle
HoverCardHandle<Payload>–
payloadRepassado ao card quando este gatilho o abre.
Payload–
renderRenderize seu próprio link, como <Button variant="link" /> ou um link do router.
ReactElement | (props, state) => ReactElement<a>
AtributoDescrição
data-slot="hover-card-trigger"Selecione o gatilho no CSS.
data-popup-openPresente enquanto o card deste gatilho está aberto.
PropTipoPadrão
side
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""center"
arrowMostra uma seta apontando para o gatilho.
booleanfalse
sideOffset
number | OffsetFunction6, or 10 with arrow
alignOffset
number | OffsetFunction0
collisionPaddingEspaço mantido entre o card e a borda do viewport.
number | Rect8
collisionAvoidanceSe o card se inverte, se desloca ou não faz nada em uma colisão.
CollisionAvoidance–
sticky
booleanfalse
anchorPosiciona em relação a algo diferente do gatilho.
Element | RefObject | VirtualElement–
positionMethod
"absolute" | "fixed""absolute"
disableAnchorTracking
booleanfalse
portalPropsProps do portal, como container.
HoverCardPortalProps–
render
ReactElement | (props, state) => ReactElement<div>
AtributoDescrição
data-slot="hover-card-content"Selecione o card no CSS.
data-openPresente enquanto o card está aberto.
data-starting-stylePresente enquanto o card anima a entrada.
data-ending-stylePresente enquanto o card anima a saída.
data-instantfocus quando o foco do teclado abriu o card, dismiss quando Escape ou um clique fora o fechou. A animação de saída é ignorada enquanto estiver definido.
data-sideO lado em que o card se fixou após as colisões.
data-alignO alinhamento em que ele se fixou.
--transform-originDe onde a animação de escala cresce, ao lado do gatilho.
--available-widthEspaço restante ao lado do gatilho. O card nunca cresce além dele.
--available-heightEspaço restante acima ou abaixo. Conteúdo mais alto rola.
Atributo do positionerDescrição
data-slot="hover-card-positioner"O elemento que se move. Ele desliza quando um card compartilhado troca de link.
data-anchor-hiddenPresente quando o gatilho sai da vista por rolagem.
Partes internasDescrição
data-slot="hover-card-viewport"Envolve o conteúdo. Carrega data-activation-direction enquanto um card compartilhado troca de link.
data-slot="hover-card-body"Seu conteúdo. A altura se ajusta suavemente quando ele muda.
data-slot="hover-card-arrow"A seta, com data-side para sua borda.
--popup-heightDefinido no card enquanto ele se redimensiona entre links.
PropTipoPadrão
container
HTMLElement | ShadowRoot | RefObject | nulldocument.body
keepMounted
booleanfalse

Retorna um handle para gatilhos desanexados. Seus métodos open(triggerId) e close() controlam o card a partir de event handlers, e isOpen lê seu estado. Passe um argumento de tipo para tipar o payload.

Usado em blocos

Blocos que se baseiam em Hover card.