HextaUI

Hover card

Eine Vorschaukarte, die sich öffnet, wenn ein Link mit der Maus berührt oder fokussiert wird, für Inhalte, die sehende Nutzer kurz überfliegen können.

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

Fügt die Komponente, die HextaUI-Theme-Tokens und alle HextaUI-Komponenten hinzu, von denen sie abhängt.

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>

Eine Hover Card ist eine Vorschau, kein Menü und kein Dialog. Der Trigger bleibt ein normaler Link, daher muss alles in der Karte auch auf der verlinkten Seite stehen.

HoverCard
├── HoverCardTrigger
└── HoverCardContent

Seite

Setze side und align auf <HoverCardContent />. Logische Seiten wie inline-end folgen der Leserichtung, und die Karte klappt um oder verschiebt sich, wenn sie den Bildschirm verlassen würde.

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

Verzögerung

delay und closeDelay am Trigger legen fest, wie lange der Zeiger ruhen muss, bevor sich die Karte öffnet, und wie lange sie nach dem Verlassen verweilt. Der Standard von 600 ms verhindert, dass Karten aufblitzen, wenn der Zeiger über eine Seite fährt.

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

Nutze render, um den Trigger zu einem beliebigen Link zu machen, auch einem innerhalb eines Satzes. Bricht ein Link auf zwei Zeilen um, verankert sich die Karte an der Zeile, die du gehovert hast.

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

Interaktiver Inhalt

Bewege den Zeiger vom Link in die Karte, und sie bleibt geöffnet, sodass Links und Buttons darin angeklickt werden können. Der Weg dazwischen ist großzügig, eine diagonale Bewegung schließt sie also nicht.

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

Geteilte Karte

Eine Karte bedient viele Links. Erstelle ein Handle mit createHoverCardHandle, gib jedem Trigger ein payload und lies es in der Karte. Beim Wechsel zwischen Namen gleitet die Karte zum neuen Link, statt zu schließen und neu zu öffnen. Der alte Inhalt gleitet in die Richtung hinaus, in die du dich bewegt hast, der neue gleitet herein, und die Höhe passt sich dazwischen sanft an.

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

Pfeil

arrow fügt einen Zeiger hinzu, der nahtlos an den Rahmen der Karte anschließt. Der Seitenversatz wächst, um Platz dafür zu schaffen, und der Zeiger folgt der Karte, wenn sie umklappt.

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

Inhalt wird geladen

Starte das Laden in onOpenChange und zeige ein Skeleton, bis die Daten eintreffen. Ändert sich der Inhalt, passt sich die Karte sanft an ihre neue Höhe an, statt zu springen.

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

Kontrolliert

Übergib open und onOpenChange. Das zweite Argument nennt den Grund der Änderung, etwa trigger-hover, trigger-focus oder 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>
  )
}

Langer Inhalt

Text ohne Umbruchstelle wird in der Karte umgebrochen, und eine Karte, die höher als der Platz neben dem Trigger ist, scrollt, statt den Bildschirm zu verlassen.

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

Rechts nach links

Die Karte liest die Richtung des Triggers, sodass logische Seiten und Ausrichtung umschlagen und die Skalierungsanimation von der richtigen Ecke aus wächst.

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>
  )
}
TasteAktion
TabDas Fokussieren des Triggers öffnet die Karte nach derselben Verzögerung wie beim Hovern. Das Weiterbewegen des Fokus schließt sie.
EnterFolgt dem Link, wie jeder andere Link.
EscSchließt die Karte.
  • Die Karte ist ein visueller Zusatz für sehende Maus- und Tastaturnutzer. Screenreader hören nur den Link, sodass sie nicht bei jedem Link, an dem sie vorbeikommen, durch eine Vorschau gezwungen werden.
  • Auf Touchscreens, wo es kein Hovern gibt, öffnet sich nichts. Ein Tippen folgt dem Link, weshalb das Ziel dieselben Informationen enthalten muss.
  • Der Fokus wandert nie in die Karte. Wenn sie Bedienelemente braucht, die per Tastatur erreichbar sein müssen, verwende stattdessen ein Popover.
  • Bei reduzierter Bewegung blendet die Karte ohne Skalierung ein und aus, und eine geteilte Karte springt zwischen Links, statt zu gleiten.

Basiert auf der Base UI Preview Card. Jeder Teil akzeptiert die Props der Primitive, die er umschließt.

PropTypStandard
open
boolean–
defaultOpen
booleanfalse
onOpenChangedetails.reason ist trigger-hover, trigger-focus, trigger-press, outside-press, escape-key, imperative-action oder none.
(open: boolean, details) => void–
onOpenChangeCompleteWird aufgerufen, nachdem die Öffnen- oder Schließen-Animation endet.
(open: boolean) => void–
handleVerbindet Trigger, die außerhalb der Root gerendert werden.
HoverCardHandle<Payload>–
childrenVerwende die Funktionsform, um das Payload des Triggers zu lesen, der die Karte geöffnet hat.
ReactNode | ({ payload }) => ReactNode–
actionsRef
RefObject<{ close, unmount }>–
PropTypStandard
href
string–
delayMillisekunden, bevor Hover oder Fokus die Karte öffnet.
number600
closeDelayMillisekunden, die die Karte nach dem Verlassen geöffnet bleibt.
number300
handle
HoverCardHandle<Payload>–
payloadWird an die Karte übergeben, wenn dieser Trigger sie öffnet.
Payload–
renderRendere deinen eigenen Link, etwa <Button variant="link" /> oder einen Router-Link.
ReactElement | (props, state) => ReactElement<a>
AttributBeschreibung
data-slot="hover-card-trigger"Den Trigger in CSS ansprechen.
data-popup-openVorhanden, solange die Karte dieses Triggers geöffnet ist.
PropTypStandard
side
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""center"
arrowZeigt einen Zeiger in Richtung des Triggers.
booleanfalse
sideOffset
number | OffsetFunction6, or 10 with arrow
alignOffset
number | OffsetFunction0
collisionPaddingAbstand, der zwischen der Karte und dem Viewport-Rand bleibt.
number | Rect8
collisionAvoidanceOb die Karte bei einer Kollision umklappt, sich verschiebt oder nichts tut.
CollisionAvoidance–
sticky
booleanfalse
anchorAn etwas anderem als dem Trigger positionieren.
Element | RefObject | VirtualElement–
positionMethod
"absolute" | "fixed""absolute"
disableAnchorTracking
booleanfalse
portalPropsProps für das Portal, etwa container.
HoverCardPortalProps–
render
ReactElement | (props, state) => ReactElement<div>
AttributBeschreibung
data-slot="hover-card-content"Die Card in CSS ansprechen.
data-openVorhanden, solange die Karte geöffnet ist.
data-starting-styleVorhanden, während die Karte eingeblendet wird.
data-ending-styleVorhanden, während die Karte ausgeblendet wird.
data-instantfocus, wenn der Tastaturfokus die Karte geöffnet hat, dismiss, wenn Escape oder ein Klick nach außen sie geschlossen hat. Die Ausblendanimation wird übersprungen, solange es gesetzt ist.
data-sideDie Seite, auf der sich die Karte nach Kollisionen eingestellt hat.
data-alignDie Ausrichtung, auf die es sich eingestellt hat.
--transform-originDer Punkt neben dem Trigger, von dem aus die Skalierungsanimation wächst.
--available-widthVerbleibender Platz neben dem Trigger. Die Karte wird nie größer als dieser.
--available-heightVerbleibender Platz oberhalb oder unterhalb. Höherer Inhalt scrollt.
Positioner-AttributBeschreibung
data-slot="hover-card-positioner"Das Element, das sich bewegt. Es gleitet, wenn eine geteilte Karte den Link wechselt.
data-anchor-hiddenVorhanden, wenn der Trigger aus dem Sichtbereich scrollt.
Innere TeileBeschreibung
data-slot="hover-card-viewport"Umschließt den Inhalt. Trägt data-activation-direction, während eine geteilte Karte den Link wechselt.
data-slot="hover-card-body"Dein Inhalt. Seine Höhe passt sich sanft an, wenn er sich ändert.
data-slot="hover-card-arrow"Der Zeiger, mit data-side für seinen Rand.
--popup-heightWird an der Karte gesetzt, während sie zwischen Links ihre Größe ändert.
PropTypStandard
container
HTMLElement | ShadowRoot | RefObject | nulldocument.body
keepMounted
booleanfalse

Gibt ein Handle für losgelöste Trigger zurück. Seine Methoden open(triggerId) und close() steuern die Karte aus Event-Handlern, und isOpen liest ihren Zustand. Übergib ein Typargument, um das Payload zu typisieren.

In Blocks verwendet

Blocks, die auf Hover card aufbauen.