这条评论来自@星见书音,发表于三天前。
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>
)
}
用法
import { HoverCard } from '@hina-ui/react'
悬停卡片给一个链接配上预览:指针在触发器上停留一段时间后浮出卡片,移开后收回;用键盘聚焦触发器时也按 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>
)
}
示例
位置
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>
)
}
延时
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>
)
}
受控
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>
)
}
外部锚点
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>
)
}
虚拟锚点与持续跟随
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>
)
}
定位层样式
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>
)
}
行为
- 指针停留
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 | 卡片内容 |