HoverCard 悬停卡片

悬停在链接上时浮出的预览卡片。

这条评论来自@星见书音,发表于三天前。

import { Avatar, HoverCard, Inline, Link, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Text>
      这条评论来自
      <HoverCard
        content={
          <Stack gap="sm" className="w-64">
            <Inline gap="sm" align="center">
              <Avatar src="/avatars/selfie.webp" name="星见书音" size="md" />
              <Stack gap="none">
                <Text weight="medium">星见书音</Text>
                <Text tone="muted" size="sm">
                  @shion
                </Text>
              </Stack>
            </Inline>
            <Text size="sm">读书、写字、偶尔画画。正在补完今年的新番。</Text>
            <Inline gap="md">
              <Text size="sm">
                <Text as="span" weight="medium">
                  128
                </Text>
                关注
              </Text>
              <Text size="sm">
                <Text as="span" weight="medium">
                  2,048
                </Text>
                粉丝
              </Text>
            </Inline>
          </Stack>
        }
      >
        <Link href="#">@星见书音</Link>
      </HoverCard>
      ,发表于三天前。
    </Text>
  )
}
tsx

用法

import { HoverCard } from '@hina-ui/react'
ts

悬停卡片给一个链接配上预览:指针在触发器上停留一段时间后浮出卡片,移开后收回;用键盘聚焦触发器时也按 openDelay 延时浮出。children 是触发器,通常是一个链接;content 属性是卡片内容。它只是看一眼的东西,不停止页面滚动,也不接管焦点。

import { HoverCard, Link, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <HoverCard
      content={
        <Stack gap="xs" className="w-64">
          <Text weight="medium">Reka UI</Text>
          <Text tone="muted" size="sm">
            一套无样式、可访问的 Vue 组件基础,本库的大部分交互件建在它上面。
          </Text>
        </Stack>
      }
    >
      <Link href="#">Reka UI</Link>
    </HoverCard>
  )
}
tsx

示例

位置

side 指定卡片朝哪个方向浮出,align 指定它与触发器的对齐方式,与 Popover 相同,默认在正下方。

import { HoverCard, Inline, Link, Text } from '@hina-ui/react'

const sides = ['top', 'right', 'bottom', 'left'] as const
const names = { top: '上方', right: '右侧', bottom: '下方', left: '左侧' }

export default function Demo() {
  return (
    <Inline gap="lg">
      {sides.map(side => (
        <HoverCard
          key={side}
          side={side}
          content={<Text size="sm">从{names[side]}浮出的卡片。</Text>}
        >
          <Link href="#">{names[side]}</Link>
        </HoverCard>
      ))}
    </Inline>
  )
}
tsx

延时

openDelay 是指针停留多久后浮出,closeDelay 是移开多久后收回,单位毫秒。指针从触发器移进卡片时不会收回。

import { HoverCard, Inline, Link, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Inline gap="lg">
      <HoverCard
        openDelay={0}
        closeDelay={0}
        content={<Text size="sm">没有延时,指针一到就出现。</Text>}
      >
        <Link href="#">立即浮出</Link>
      </HoverCard>
      <HoverCard
        openDelay={800}
        closeDelay={400}
        content={<Text size="sm">停留 800 毫秒才出现,移开 400 毫秒后收回。</Text>}
      >
        <Link href="#">停留久一点</Link>
      </HoverCard>
    </Inline>
  )
}
tsx

受控

open 可受控。卡片仍然会因指针移开、点击外部或者按 Esc 而收回,并把 false 写回,外部的控件只负责打开。

查看预览

已收回

'use client'

import { useState } from 'react'
import { Button, HoverCard, Inline, Link, Text } from '@hina-ui/react'

export default function Demo() {
  const [open, setOpen] = useState(false)

  return (
    <Inline gap="lg" align="center">
      <Button variant="outline" tone="neutral" disabled={open} onClick={() => setOpen(true)}>
        打开卡片
      </Button>
      <HoverCard
        open={open}
        onOpenChange={setOpen}
        content={<Text size="sm">卡片由外部打开;指针移开、点击外部或者按 Esc 都会收回。</Text>}
      >
        <Link href="#">查看预览</Link>
      </HoverCard>
      <Text tone="muted" size="sm">
        {open ? '已打开' : '已收回'}
      </Text>
    </Inline>
  )
}
tsx

外部锚点

children 留空时,anchor 接收外部元素,用 open / onOpenChange 控制打开。切换 anchor 与卡片内容即可让多个触发点共用一个卡片。提供 children 时,仍使用 children 中的触发器。

外部锚点不会自动打开卡片。直接设置 open = true 会立即打开,不经过 openDelay;移开后仍按 closeDelay 收回,移入卡片保持打开,键盘焦点离开锚点后收回。锚点清空、从文档移除,或其外层发生滚动时,卡片关闭。

示例使用 Button 绑定指针和键盘焦点事件。

退场期间仍跟随页面上有效的锚点;锚点清空或移除后,使用最后的位置完成退场。

'use client'

import { useState, type FocusEvent, type PointerEvent } from 'react'
import { Button, HoverCard, Inline, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  const [open, setOpen] = useState(false)
  const [anchor, setAnchor] = useState<HTMLElement | null>(null)
  const [current, setCurrent] = useState(1)

  function show(event: PointerEvent<HTMLElement> | FocusEvent<HTMLElement>, index: number) {
    const target = event.currentTarget
    if ('pointerType' in event ? event.pointerType === 'touch' : !target.matches(':focus-visible'))
      return
    setAnchor(target)
    setCurrent(index)
    setOpen(true)
  }

  return (
    <Inline>
      {[1, 2, 3].map(index => (
        <Button
          key={index}
          variant="outline"
          tone="neutral"
          onPointerEnter={event => show(event, index)}
          onFocus={event => show(event, index)}
        >
          锚点 {index}
        </Button>
      ))}
      <HoverCard
        open={open}
        onOpenChange={setOpen}
        anchor={anchor}
        content={
          <Stack gap="xs" className="w-56">
            <Text weight="medium">锚点 {current}</Text>
            <Text tone="muted" size="sm">
              同一个卡片随当前锚点切换位置与内容。
            </Text>
          </Stack>
        }
      />
    </Inline>
  )
}
tsx

虚拟锚点与持续跟随

anchor 也接受带 getBoundingClientRect() 的对象,返回视口坐标中的矩形。OverlayAnchor 类型可从包根导入;回调需要返回最新坐标,同一个对象不必反复替换。

可选的 contextElement 指定坐标所属的元素,用于识别滚动祖先和裁剪边界;它不会成为触发器,也不会扩大浮层的交互区域。

updatePositionStrategy 默认是 'optimized',在滚动、尺寸和布局变化时更新位置。设置为 'always' 后,挂载期间逐帧检查矩形,持续跟随仅有坐标变化的锚点。可以在打开期间切换策略。退场期间继续跟随,锚点清空或所属元素移除后保留最后的位置,卸载后停止测量。

虚拟锚点没有悬停区域,开关由 open / onOpenChange 控制;指针移开和祖先滚动不会关闭卡片,点击外部或按 Esc 仍会关闭。元素锚点的悬停、延迟关闭和滚动关闭行为不变。

示例用 Button 打开面板,并用 ScrollArea 提供滚动容器。面板同时跟随坐标变化和容器滚动。

向下滚动可观察定位变化。

'use client'

import { useEffect, useRef, useState } from 'react'
import {
  Button,
  ScrollArea,
  Inline,
  Stack,
  Text,
  HoverCard,
  type OverlayAnchor,
} from '@hina-ui/react'

export default function Demo() {
  const [open, setOpen] = useState(false)
  const surface = useRef<HTMLElement>(null)
  const [anchor, setAnchor] = useState<OverlayAnchor | null>(null)
  const [x, setX] = useState(80)
  const position = useRef(80)

  useEffect(() => {
    if (!open) return
    let frame = 0
    let previous = 0
    function tick(now: number) {
      const delta = previous ? now - previous : 0
      previous = now
      position.current = 80 + ((position.current - 80 + delta / 35) % 120)
      setX(position.current)
      frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [open])

  function show() {
    const element = surface.current
    if (!element) return
    setAnchor({
      contextElement: element,
      getBoundingClientRect: () => {
        const rect = element.getBoundingClientRect()
        return new DOMRect(rect.left + position.current, rect.top + 100, 2, 20)
      },
    })
    setOpen(true)
  }

  return (
    <Stack className="w-full">
      <Inline>
        <Button variant="outline" tone="neutral" onClick={show}>
          打开
        </Button>
        <Text size="sm" tone="muted">
          向下滚动可观察定位变化。
        </Text>
      </Inline>
      <ScrollArea className="h-64 rounded-lg border border-line bg-inset">
        <Stack ref={surface} className="relative h-128 shrink-0">
          <Text
            as="span"
            aria-hidden="true"
            className="absolute top-25 h-5 w-0.5 bg-accent"
            style={{ left: `${x}px` }}
          />
        </Stack>
      </ScrollArea>
      <HoverCard
        open={open}
        onOpenChange={setOpen}
        anchor={anchor}
        updatePositionStrategy="always"
        align="start"
        content={<Text size="sm">移动坐标</Text>}
      />
    </Stack>
  )
}
tsx

定位层样式

positionerClass 为外层定位节点追加类名,className 仍作用于内层卡片。可以使用 utility 或全局 CSS 类定义移动过渡,与卡片的入退场动画分别控制。

示例在卡片已打开且切换锚点时启用 hn-transition-base,首次打开和关闭时移除移动过渡;减少动态效果偏好下不播放移动动画。触发器使用 Button。

'use client'

import { useState, type FocusEvent, type PointerEvent } from 'react'
import { Button, HoverCard, Inline, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  const [open, setOpen] = useState(false)
  const [anchor, setAnchor] = useState<HTMLElement | null>(null)
  const [current, setCurrent] = useState(1)
  const [moving, setMoving] = useState(false)

  function show(event: PointerEvent<HTMLElement> | FocusEvent<HTMLElement>, index: number) {
    const target = event.currentTarget
    if ('pointerType' in event ? event.pointerType === 'touch' : !target.matches(':focus-visible'))
      return
    if (!open) setMoving(false)
    else if (anchor !== target) setMoving(true)
    setAnchor(target)
    setCurrent(index)
    setOpen(true)
  }

  return (
    <Inline>
      {[1, 2, 3].map(index => (
        <Button
          key={index}
          variant="outline"
          tone="neutral"
          onPointerEnter={event => show(event, index)}
          onFocus={event => show(event, index)}
        >
          锚点 {index}
        </Button>
      ))}
      <HoverCard
        open={open}
        onOpenChange={setOpen}
        anchor={anchor}
        positionerClass={
          open && moving ? 'hn-transition-base motion-reduce:transition-none' : undefined
        }
        content={
          <Stack gap="xs" className="w-56">
            <Text weight="medium">锚点 {current}</Text>
            <Text tone="muted" size="sm">
              同一个卡片平滑移动到当前锚点。
            </Text>
          </Stack>
        }
      />
    </Inline>
  )
}
tsx

行为

  • 指针停留 openDelay 后浮出,移开 closeDelay 后收回;移进卡片内保持打开。
  • 键盘聚焦 children 中的触发器时按 openDelay 浮出,焦点离开后按 closeDelay 收回。
  • 关闭延迟内移回卡片会取消关闭;退场动画开始后,卡片内容不再响应交互,移入原区域不会重新打开。
  • 点击卡片外部或者按 Esc 也会收回。
  • 卡片不停止页面滚动,页面其他部分照常可以交互。
  • 触屏设备上不会因触摸浮出。

无障碍

  • 卡片只是补充信息,其中的内容不应该是到达某处的唯一途径。
  • 触发器应当是可以聚焦的元素,键盘用户才能看到卡片。

API

Props

属性
类型
默认值
说明
anchor
OverlayAnchor | null
—
定位元素或虚拟锚点,children 为空时使用
updatePositionStrategy
'optimized' | 'always'
'optimized'
定位更新策略
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
浮出的方向
align
'start' | 'center' | 'end'
'center'
与触发器的对齐方式
sideOffset
number
8
与触发器的距离,像素
openDelay
number
300
停留多久后浮出,毫秒
closeDelay
number
150
移开多久后收回,毫秒
padded
boolean
true
卡片是否带内边距
open
boolean
—
是否打开,可受控
className
string
—
追加至卡片的类名
positionerClass
string
—
追加至外层定位节点的类名

内容属性

属性
说明
children
可选触发器;留空时使用 anchor
content
卡片内容