HextaUI

Hover card

リンクにホバーまたはフォーカスすると開くプレビューカードです。視覚で閲覧するユーザーがさっと確認できる内容向けです。

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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

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>

ホバーカードはプレビューであり、メニューやダイアログではありません。トリガーは通常のリンクのままなので、カード内のすべての内容は、リンク先のページにも存在する必要があります。

HoverCard
├── HoverCardTrigger
└── HoverCardContent

側

<HoverCardContent /> に side と align を設定します。inline-end のような論理的な側は読む方向に従い、カードは画面から出そうなときに反転またはシフトします。

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

遅延

トリガーの delay と closeDelay は、カードが開くまでにポインターが静止しなければならない時間と、離れた後に残る時間を設定します。デフォルトの 600ms は、ポインターがページを横切る際にカードが一瞬開くのを防ぎます。

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

render を使うと、文中のリンクを含め、任意のリンクをトリガーにできます。リンクが 2 行に折り返された場合、カードはホバーした行にアンカーされます。

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

インタラクティブなコンテンツ

ポインターをリンクからカードに移動してもカードは開いたままなので、内側のリンクやボタンをクリックできます。両者の間の経路には余裕があるため、斜めに動かしても閉じません。

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

共有カード

1 つのカードが複数のリンクに対応します。createHoverCardHandle でハンドルを作成し、各トリガーに payload を与え、カード内でそれを読み取ります。名前の間を移動すると、カードは閉じて開き直すのではなく、新しいリンクへ滑らかに移動します。古いコンテンツは動いた方向へスライドアウトし、新しいコンテンツがスライドインし、高さは両者の間でイージングします。

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

矢印

arrow は、カードの枠線と継ぎ目なくつながる矢印を追加します。側のオフセットは矢印の分だけ広がり、カードが反転すると矢印も追従します。

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

読み込み中のコンテンツ

onOpenChange で取得を開始し、データが届くまでスケルトンを表示します。コンテンツが変わると、カードは跳ねずに新しい高さへイージングします。

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

制御

open と onOpenChange を渡します。第 2 引数は、trigger-hover、trigger-focus、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>
  )
}

長いコンテンツ

区切りのないテキストはカード内で折り返され、トリガーの横の空間より高いカードは、画面から出ずにスクロールします。

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

右から左

カードはトリガーの方向を読み取るため、論理的な側と揃え位置は反転し、スケールアニメーションは正しい角から拡大します。

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>
  )
}
キーアクション
Tabトリガーにフォーカスすると、ホバーと同じ遅延の後にカードが開きます。フォーカスが移動すると閉じます。
Enter他のリンクと同様に、リンク先へ移動します。
Escカードを閉じます。
  • カードは、マウスとキーボードを使う晴眼のユーザー向けの視覚的な追加要素です。スクリーンリーダーにはリンクだけが読み上げられるため、通り過ぎるすべてのリンクでプレビューを読まされることはありません。
  • ホバーのないタッチスクリーンでは、何も開きません。タップするとリンク先に移動するため、リンク先にも同じ情報が含まれている必要があります。
  • フォーカスがカード内に移動することはありません。キーボードで到達できる必要のあるコントロールが必要な場合は、代わりにポップオーバーを使ってください。
  • モーションの低減が有効な場合、カードは拡大縮小せずにフェードし、共有カードはリンク間を滑らかに移動せずにジャンプします。

Base UI の preview card をベースにしています。各パーツは、ラップしているプリミティブの props を受け付けます。

プロパティ型デフォルト
open
boolean–
defaultOpen
booleanfalse
onOpenChangedetails.reason は、trigger-hover、trigger-focus、trigger-press、outside-press、escape-key、imperative-action、none のいずれかです。
(open: boolean, details) => void–
onOpenChangeComplete開閉アニメーションの終了後に呼ばれます。
(open: boolean) => void–
handleルートの外側にレンダリングされたトリガーを接続します。
HoverCardHandle<Payload>–
childrenカードを開いたトリガーの payload を読み取るには、関数形式を使います。
ReactNode | ({ payload }) => ReactNode–
actionsRef
RefObject<{ close, unmount }>–
プロパティ型デフォルト
href
string–
delayホバーまたはフォーカスしてからカードが開くまでのミリ秒。
number600
closeDelay離れた後、カードが開いたままでいるミリ秒。
number300
handle
HoverCardHandle<Payload>–
payloadこのトリガーがカードを開いたときに、カードに渡されます。
Payload–
render<Button variant="link" /> やルーターのリンクなど、独自のリンクをレンダリングします。
ReactElement | (props, state) => ReactElement<a>
属性説明
data-slot="hover-card-trigger"CSSでトリガーを指定します。
data-popup-openこのトリガーのカードが開いている間、付与されます。
プロパティ型デフォルト
side
"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""center"
arrowトリガーの方向を指す矢印を表示します。
booleanfalse
sideOffset
number | OffsetFunction6, or 10 with arrow
alignOffset
number | OffsetFunction0
collisionPaddingカードとビューポートの端の間に確保される空間。
number | Rect8
collisionAvoidance衝突時に、カードが反転するか、シフトするか、何もしないか。
CollisionAvoidance–
sticky
booleanfalse
anchorトリガー以外のものを基準に配置します。
Element | RefObject | VirtualElement–
positionMethod
"absolute" | "fixed""absolute"
disableAnchorTracking
booleanfalse
portalPropscontainer など、ポータル用の props。
HoverCardPortalProps–
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="hover-card-content"CSSでカードを指定します。
data-openカードが開いている間、付与されます。
data-starting-styleカードが表示アニメーションをしている間、付与されます。
data-ending-styleカードが非表示アニメーションをしている間、付与されます。
data-instantキーボードフォーカスでカードが開いた場合は focus、Escape または外側の押下で閉じた場合は dismiss。設定されている間、終了アニメーションはスキップされます。
data-side衝突の処理後にカードが決まった側。
data-align最終的に決まった揃え位置。
--transform-originトリガーの隣にある、スケールアニメーションの拡大の起点。
--available-widthトリガーの横に残っている空間。カードがそれを超えて広がることはありません。
--available-height上または下に残っている空間。それより高いコンテンツはスクロールします。
Positioner の属性説明
data-slot="hover-card-positioner"動く要素。共有カードがリンクを切り替えるときに滑らかに移動します。
data-anchor-hiddenトリガーがスクロールで見えなくなったときに付与されます。
内部パーツ説明
data-slot="hover-card-viewport"コンテンツを包みます。共有カードがリンクを切り替える間、data-activation-direction を持ちます。
data-slot="hover-card-body"あなたのコンテンツ。変化すると高さがイージングします。
data-slot="hover-card-arrow"ポインター。その端を示す data-side を持ちます。
--popup-heightリンク間でカードのサイズが変わっている間、カードに設定されます。
プロパティ型デフォルト
container
HTMLElement | ShadowRoot | RefObject | nulldocument.body
keepMounted
booleanfalse

分離されたトリガー用のハンドルを返します。その open(triggerId) と close() メソッドでイベントハンドラーからカードを制御でき、isOpen で状態を読み取れます。payload に型を付けるには、型引数を渡します。

使用しているブロック

Hover card の上に構築されるブロック。