React 中的轻量级 Tooltip 实现方法
你需要一个 tooltip(工具提示)。也许是为图标按钮提供提示,或者为截断的标签提供上下文信息。在使用完整的 UI 框架或像 Tippy.js 这样的传统库之前,不妨考虑一下实际需要多少代码。
本指南将介绍 React 中的轻量级 tooltip 模式——从最简单的原生方法到最小化的自定义 hooks,再到像 Floating UI 这样的 headless tooltip 库。每一步都在不增加打包体积的前提下增加功能。
核心要点
- 原生
title属性提供零成本的 tooltip,但缺乏样式定制和键盘支持 - 纯 CSS tooltip 可以在不使用 JavaScript 的情况下提供样式和焦点状态,但无法处理视口碰撞
- 最小化的自定义 hook 提供编程控制,同时保持较小的打包体积
- Floating UI 提供碰撞检测和视口感知功能,体积约 3kB
- 始终优先考虑无障碍性,使用适当的 ARIA 属性,永远不要将关键信息仅放在 tooltip 中
从原生 Title 属性开始
浏览器内置的 title 属性是零 JavaScript 的基准方案:
<button title="Save your changes">💾</button>
这种方法有效,但功能有限。tooltip 会在延迟后出现,你无法自定义样式,并且在键盘聚焦时不会显示。不过,对于真正简单的场景,它不需要任何成本。
纯 CSS 的 React Tooltip
对于不需要 JavaScript 逻辑的样式化 tooltip,CSS 可以处理基本的悬停状态:
function IconButton({ label, children }) {
return (
<button className="tooltip-trigger" aria-describedby="tooltip-id">
{children}
<span className="tooltip" id="tooltip-id" role="tooltip">{label}</span>
</button>
)
}
.tooltip-trigger {
position: relative;
}
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.tooltip-trigger:hover .tooltip,
.tooltip-trigger:focus .tooltip {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
transition: none;
}
}
这种纯 CSS 模式遵循 prefers-reduced-motion 设置,并在悬停和聚焦时触发。对于 React tooltip 的无障碍性,按钮上的 role="tooltip" 和 aria-label 为屏幕阅读器提供了上下文。
局限性是什么?没有碰撞检测。如果 tooltip 位于视口边缘附近,它会被裁剪。
最小化的自定义 Hook
当你需要编程控制而不想使用库时,一个小型 hook 就能很好地工作:
import { useState, useCallback } from 'react'
function useTooltip() {
const [isOpen, setIsOpen] = useState(false)
const triggerProps = {
onMouseEnter: useCallback(() => setIsOpen(true), []),
onMouseLeave: useCallback(() => setIsOpen(false), []),
onFocus: useCallback(() => setIsOpen(true), []),
onBlur: useCallback(() => setIsOpen(false), []),
}
return { isOpen, triggerProps }
}
使用方式:
function TooltipButton({ hint, children }) {
const { isOpen, triggerProps } = useTooltip()
const id = `tooltip-${React.useId()}`
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<button {...triggerProps} aria-describedby={isOpen ? id : undefined}>
{children}
</button>
{isOpen && (
<span id={id} role="tooltip" className="tooltip">
{hint}
</span>
)}
</span>
)
}
aria-describedby 关系将按钮与其 tooltip 连接起来,为辅助技术提供支持。这很重要:tooltip 应该是补充,而不是替代无障碍标签。永远不要将关键信息仅放在 tooltip 中。
Discover how at OpenReplay.com.
Headless Tooltip 库:Floating UI
当你需要适当的定位——碰撞检测、翻转、偏移——Floating UI 是现代化的选择。它是 Popper.js 的继任者,体积约 3kB。
Floating UI React tooltip 为你提供了原语,而不强加样式意见:
import {
useFloating,
offset,
flip,
shift,
useHover,
useFocus,
useInteractions,
} from '@floating-ui/react'
import { useState } from 'react'
function Tooltip({ label, children }) {
const [isOpen, setIsOpen] = useState(false)
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(6), flip(), shift()],
})
const hover = useHover(context)
const focus = useFocus(context)
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus])
return (
<>
<span ref={refs.setReference} {...getReferenceProps()}>
{children}
</span>
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
role="tooltip"
{...getFloatingProps()}
>
{label}
</div>
)}
</>
)
}
这会自动处理视口边界。当空间不足时,flip 中间件会重新定位,而 shift 则保持 tooltip 沿轴可见。
为了 SSR 安全,Floating UI 的 hooks 在渲染期间不会访问 window 或 document。如果你要构建自定义解决方案,请在 useEffect 或 useLayoutEffect 中保护这些引用。
何时使用 Portal
如果你的 tooltip 父元素具有 overflow: hidden 或复杂的层叠上下文,请在 portal 中渲染 tooltip:
import { createPortal } from 'react-dom'
{isOpen && createPortal(
<div ref={refs.setFloating} style={floatingStyles} role="tooltip">
{label}
</div>,
document.body
)}
这可以避免裁剪容器的限制。无论 tooltip 在 DOM 中的哪个位置渲染,Floating UI 都能处理定位。
为什么不用 Tippy.js?
Tippy.js 和 react-popper 多年来表现良好,但它们实际上已经是传统方案了。Floating UI 提供相同的定位引擎,但体积更小,并通过 hooks 提供更好的 React 集成。对于新项目,像 Floating UI 或 Radix UI Tooltip 这样的 headless tooltip 库是实用的选择。
选择你的方法
根据需求匹配复杂度:
- 原生
title:零成本,零控制 - 纯 CSS:有样式,可访问,无定位逻辑
- 自定义 hook:完全控制,最少代码,手动定位
- Floating UI:碰撞检测,视口感知,约 3kB
结论
Tooltip 不需要很重。从简单开始,在用例需要时添加功能,并将无障碍性放在每个实现的核心。原生 title 属性适用于基本场景,纯 CSS 解决方案处理样式需求,当你需要碰撞检测时,Floating UI 可以在不增加传统库臃肿的情况下提供功能。
常见问题
使用 aria-describedby 将触发元素与 tooltip 内容连接起来。在 tooltip 元素本身添加 role tooltip。保持 tooltip 文本简洁且具有补充性。永远不要将关键信息仅放在 tooltip 中,因为依赖键盘或屏幕阅读器的用户可能会错过延迟或仅悬停显示的内容。
纯 CSS tooltip 缺乏碰撞检测。tooltip 相对于其父元素定位,而不检查视口边界。使用带有 flip 和 shift 中间件的 Floating UI 可以在 tooltip 被裁剪时自动重新定位。或者在 portal 中渲染 tooltip 以避免 overflow hidden 容器的限制。
Floating UI 是新项目的推荐选择。它是 Popper.js(为 Tippy.js 提供支持)的继任者,提供更小的打包体积(约 3kB),并通过 hooks 提供更好的 React 集成。Tippy.js 仍然可以工作,但被认为是传统方案,比现代替代方案更重。
当 tooltip 父元素具有 overflow hidden、overflow scroll 或导致裁剪的复杂层叠上下文时使用 portal。Portal 将 tooltip 直接渲染到 document.body 中,避免任何容器限制。无论 tooltip 出现在 DOM 树的哪个位置,Floating UI 都能正确处理定位。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.