HextaUI

Hover card

Une carte d’aperçu qui s’ouvre au survol ou au focus d’un lien, pour un contenu que les personnes voyantes peuvent parcourir du regard.

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

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

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>

Une hover card est un aperçu, pas un menu ni une boîte de dialogue. Le déclencheur reste un lien normal : tout ce que contient la carte doit donc aussi se trouver sur la page vers laquelle il pointe.

HoverCard
├── HoverCardTrigger
└── HoverCardContent

Côté

Définissez side et align sur <HoverCardContent />. Les côtés logiques comme inline-end suivent le sens de lecture, et la carte se retourne ou se décale lorsqu’elle sortirait de l’écran.

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

Délai

delay et closeDelay sur le déclencheur définissent combien de temps le pointeur doit rester avant l’ouverture de la carte et combien de temps elle persiste après sa sortie. La valeur par défaut de 600 ms évite que les cartes ne s’ouvrent en un éclair quand le pointeur traverse une page.

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

Utilisez render pour faire du déclencheur n’importe quel lien, y compris un lien au milieu d’une phrase. Lorsqu’un lien passe sur deux lignes, la carte s’ancre à la ligne que vous avez survolée.

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

Contenu interactif

Déplacez le pointeur du lien vers la carte et elle reste ouverte : les liens et boutons à l’intérieur peuvent donc être cliqués. Le trajet entre les deux est tolérant, un déplacement en diagonale ne la ferme donc pas.

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

Carte partagée

Une seule carte sert de nombreux liens. Créez un handle avec createHoverCardHandle, donnez un payload à chaque déclencheur et lisez-le dans la carte. Passer d’un nom à l’autre fait glisser la carte vers le nouveau lien au lieu de la fermer puis de la rouvrir. L’ancien contenu sort dans le sens de votre déplacement, le nouveau entre, et la hauteur s’adapte entre les deux.

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

Flèche

arrow ajoute une pointe qui se raccorde à la bordure de la carte sans couture. Le décalage latéral augmente pour lui faire de la place, et elle suit la carte lorsqu’elle se retourne.

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

Contenu en chargement

Lancez la récupération dans onOpenChange et affichez un skeleton jusqu’à l’arrivée des données. Lorsque le contenu change, la carte s’adapte en douceur à sa nouvelle hauteur au lieu de sauter.

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

Contrôlé

Passez open et onOpenChange. Le deuxième argument indique pourquoi il a changé, par exemple 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>
  )
}

Contenu long

Le texte sans coupure passe à la ligne dans la carte, et une carte plus haute que l’espace à côté du déclencheur défile au lieu de sortir de l’écran.

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 droite à gauche

La carte lit la direction du déclencheur : les côtés logiques et l’alignement s’inversent donc et l’animation d’agrandissement part du bon coin.

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>
  )
}
ToucheAction
TabDonner le focus au déclencheur ouvre la carte après le même délai que le survol. Déplacer le focus la ferme.
EnterSuit le lien, comme n’importe quel autre lien.
EscFerme la carte.
  • La carte est un complément visuel pour les utilisateurs voyants à la souris et au clavier. Les lecteurs d’écran n’entendent que le lien : ils ne sont donc pas forcés de passer par un aperçu à chaque lien rencontré.
  • Rien ne s’ouvre sur écran tactile, où il n’y a pas de survol. Un appui suit le lien, c’est pourquoi la destination doit contenir les mêmes informations.
  • Le focus n’entre jamais dans la carte. Si elle a besoin de contrôles accessibles au clavier, utilisez plutôt un popover.
  • Avec la réduction des animations, la carte apparaît en fondu sans changer d’échelle, et une carte partagée saute d’un lien à l’autre au lieu de glisser.

Construit sur la preview card de Base UI. Chaque partie accepte les props de la primitive qu’elle enveloppe.

PropTypePar défaut
open
boolean–
defaultOpen
booleanfalse
onOpenChangedetails.reason vaut trigger-hover, trigger-focus, trigger-press, outside-press, escape-key, imperative-action ou none.
(open: boolean, details) => void–
onOpenChangeCompleteAppelé à la fin de l’animation d’ouverture ou de fermeture.
(open: boolean) => void–
handleRelie les déclencheurs rendus en dehors de la racine.
HoverCardHandle<Payload>–
childrenUtilisez la forme fonction pour lire le payload du déclencheur qui a ouvert la carte.
ReactNode | ({ payload }) => ReactNode–
actionsRef
RefObject<{ close, unmount }>–
PropTypePar défaut
href
string–
delayMillisecondes avant que le survol ou le focus n’ouvre la carte.
number600
closeDelayMillisecondes pendant lesquelles la carte reste ouverte après la sortie.
number300
handle
HoverCardHandle<Payload>–
payloadTransmis à la carte lorsque ce déclencheur l’ouvre.
Payload–
renderRendez votre propre lien, comme <Button variant="link" /> ou un lien de routeur.
ReactElement | (props, state) => ReactElement<a>
AttributDescription
data-slot="hover-card-trigger"Ciblez le déclencheur en CSS.
data-popup-openPrésent tant que la carte de ce déclencheur est ouverte.
PropTypePar défaut
side
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""center"
arrowAffiche une pointe dirigée vers le déclencheur.
booleanfalse
sideOffset
number | OffsetFunction6, or 10 with arrow
alignOffset
number | OffsetFunction0
collisionPaddingEspace conservé entre la carte et le bord du viewport.
number | Rect8
collisionAvoidanceIndique si la carte se retourne, se décale ou ne fait rien en cas de collision.
CollisionAvoidance–
sticky
booleanfalse
anchorSe positionne par rapport à autre chose que le déclencheur.
Element | RefObject | VirtualElement–
positionMethod
"absolute" | "fixed""absolute"
disableAnchorTracking
booleanfalse
portalPropsProps du portail, comme container.
HoverCardPortalProps–
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="hover-card-content"Ciblez la carte en CSS.
data-openPrésent tant que la carte est ouverte.
data-starting-stylePrésent pendant l’animation d’entrée de la carte.
data-ending-stylePrésent pendant l’animation de sortie de la carte.
data-instantfocus lorsque le focus clavier a ouvert la carte, dismiss lorsque Escape ou un appui à l’extérieur l’a fermée. L’animation de sortie est ignorée tant qu’il est défini.
data-sideLe côté retenu par la carte après les collisions.
data-alignL’alignement retenu.
--transform-originLe point d’où part l’animation d’agrandissement, à côté du déclencheur.
--available-widthEspace restant à côté du déclencheur. La carte ne le dépasse jamais.
--available-heightEspace restant au-dessus ou en dessous. Un contenu plus haut défile.
Attribut du positionneurDescription
data-slot="hover-card-positioner"L’élément qui bouge. Il glisse lorsqu’une carte partagée change de lien.
data-anchor-hiddenPrésent lorsque le déclencheur sort de la vue par le défilement.
Parties internesDescription
data-slot="hover-card-viewport"Enveloppe le contenu. Porte data-activation-direction pendant qu’une carte partagée change de lien.
data-slot="hover-card-body"Votre contenu. Sa hauteur s’adapte en douceur lorsqu’il change.
data-slot="hover-card-arrow"La pointe, avec data-side pour son bord.
--popup-heightDéfini sur la carte pendant qu’elle se redimensionne entre les liens.
PropTypePar défaut
container
HTMLElement | ShadowRoot | RefObject | nulldocument.body
keepMounted
booleanfalse

Retourne un handle pour les déclencheurs détachés. Ses méthodes open(triggerId) et close() contrôlent la carte depuis des gestionnaires d’événements, et isOpen lit son état. Passez un argument de type pour typer le payload.

Utilisé dans les blocks

Des blocks qui s’appuient sur Hover card.