Front-End-Checklist 实战指南:构建可访问的 Tooltip(可访问性提示框)——ARIA 关联、键盘交互与仓库设计系统实现
本篇基于 Front-End-Checklist 仓库中的可访问性规则文档 rule.md,系统讲解可访问 Tooltip 的完整实现方案:从最简 HTML 标记、ARIA 关联属性,到 React 基础组件、Floating UI 高级定位、图标按钮模式与 CSS 样式细节,并结合仓库自身基于 Radix 的设计系统实现(tooltip.tsx)与 Web 端真实用法,给出一份可复制、可验证的落地指南。
规则定位与元信息
在 Front-End-Checklist 中,该规则归属于 html / accessibility 分类(子分类 components),其元数据如下(来自规则文档与 规则内容文件 的 frontmatter):
| 属性 | 值 |
|---|---|
| 规则名称 | Create accessible tooltips(创建可访问的 Tooltip) |
| 优先级 | medium(中等) |
| 难度 | intermediate(中级) |
| 预计耗时 | 20 分钟 |
| 核心描述 | Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling |
该仓库的定位是“面向人类与 AI Agent 的现代 Web 开发检查清单”。这条规则除了作为人类开发者的检查项,还被封装为一个 AI 技能 SKILL.md,内含 Check / Fix / Explain / Code Review 四种提示词工作流,供 Agent 在审查模板、服务端渲染 HTML 和共享组件时直接调用。其中 Code Review 提示词明确要求:审查最终面向浏览器的渲染标记,而不只是源码层的框架抽象。
快速参考(Quick Reference)
规则文档给出的四条速查要点:
- Tooltip 必须可键盘访问(触发器可聚焦);
- 使用
aria-describedby将 Tooltip 与触发器程序化关联; - 允许 hover 与 focus 两种方式显示 Tooltip,并支持 Escape 关闭;
- 确保足够的颜色对比度,且不要在 Tooltip 中隐藏必要信息。
为什么可访问性 Tooltip 很重要
原文档的 "Why It Matters" 指出:不可访问的 Tooltip 会让键盘用户和屏幕阅读器用户失去重要的上下文信息,造成体验不平等和潜在困惑。典型反例是只用 title 属性或纯 CSS :hover 实现的提示框——键盘用户无法触发,屏幕阅读器用户(尤其是未聚焦到元素时)可能完全听不到提示内容。
最小 HTML 实现
规则给出的基准标记结构(来自 rule.md):
<div class="tooltip-container">
<button
type="button"
aria-describedby="tooltip-1"
class="tooltip-trigger"
>
Settings
</button>
<div
id="tooltip-1"
role="tooltip"
class="tooltip"
>
Configure your preferences
</div>
</div>
三个关键属性的作用拆解:
aria-describedby="tooltip-1":建立触发器与提示内容之间的程序化关联,屏幕阅读器在聚焦触发器时会顺带朗读被描述的内容;role="tooltip":向辅助技术声明该元素是 Tooltip 角色,影响焦点移动时的朗读行为;- 触发器本身必须是可聚焦元素(此处为
<button>)——这是键盘可访问性的前提。
五项可访问性要求
原文档以表格形式列出了可访问 Tooltip 的硬性要求与对应实现方式,此处完整保留:
| Requirement | Implementation |
|---|---|
| Keyboard accessible(键盘可访问) | Show on focus, not just hover(focus 时也要显示,不能只响应 hover) |
| Programmatically associated(程序化关联) | Use aria-describedby |
| Dismissible(可关闭) | Close with Escape key(按 Escape 关闭) |
| Persistent(持续可见) | Stay visible while hovered/focused(悬停/聚焦期间保持稳定显示) |
| Non-essential(非关键内容) | Don't hide critical info in tooltips(不要在提示框里藏关键信息) |
“Persistent”一条尤其容易被忽视:如果 Tooltip 在鼠标于触发器和提示框之间移动时闪烁消失,用户体验会非常差。实现上有两种思路:给提示框设置 pointer-events: none 并靠容器级事件维持可见,或在提示框上补充 mouseenter/mouseleave 监听。
React 基础 Tooltip 组件
规则提供了一个不依赖第三方库的完整 React 实现。注意:技能引用文档中的代码块缺失了 import 与函数声明行,以下采用仓库 规则内容文件 中完整可运行的版本:
import { useState, useRef, useEffect, useId } from 'react'
interface TooltipProps {
content: string
children: React.ReactElement
position?: 'top' | 'bottom' | 'left' | 'right'
delay?: number
}
export function Tooltip({
content,
children,
position = 'top',
delay = 300
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false)
const tooltipId = useId()
const timeoutRef = useRef<NodeJS.Timeout>()
const triggerRef = useRef<HTMLElement>(null)
const showTooltip = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay)
}
const hideTooltip = () => {
clearTimeout(timeoutRef.current)
setIsVisible(false)
}
// Handle Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isVisible) {
hideTooltip()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isVisible])
// Clone child to add props
const trigger = React.cloneElement(children, {
ref: triggerRef,
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
onFocus: showTooltip,
onBlur: hideTooltip,
})
return (
<div className="tooltip-wrapper">
{trigger}
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className={`tooltip tooltip--${position}`}
>
{content}
</div>
)}
</div>
)
}
逐点解析关键设计:
useId()生成唯一 id:tooltipId用于id与aria-describedby配对,避免多实例时 id 冲突——手写tooltip-1这类静态 id 在列表渲染场景下会产生重复 id,破坏 ARIA 关联。cloneElement注入行为与属性:触发器的onMouseEnter/onMouseLeave/onFocus/onBlur四个事件和aria-describedby都是在克隆子元素时动态附加的。aria-describedby仅在isVisible时设置,Tooltip 隐藏时移除关联,避免屏幕阅读器朗读一个不存在的节点。delay延迟显示(默认 300ms):hover 显示通过setTimeout延迟,hideTooltip会先clearTimeout,防止鼠标短暂划过时 Tooltip 频繁闪现。这个延迟策略正是仓库 Web 端实际采用的参数思路——后文会看到 rule-checkbox.tsx 使用delayDuration={250}、checklist-action-bar.tsx 使用delayDuration={300}。- Escape 键关闭:
useEffect中注册全局keydown监听,isVisible变化时正确挂载/卸载监听器,Escape时调用hideTooltip()。这满足要求表中“Dismissible”一项。 - focus 与 blur 对称处理:
onFocus: showTooltip保证键盘 Tab 聚焦时 Tooltip 出现,onBlur: hideTooltip保证焦点离开后自动收起,不残留遮挡内容。
使用方式
原文档给出的两类典型用法:
<Tooltip content="Save your current progress">
<button type="button">
<SaveIcon aria-hidden="true" />
<span className="sr-only">Save</span>
</button>
</Tooltip>
<Tooltip content="Required field" position="right">
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
</Tooltip>
注意第一个例子的细节:图标按钮用 aria-hidden="true" 屏蔽装饰性 SVG,再用 sr-only 类(screen-reader only)提供 “Save” 的可读文本;第二个例子中星号用 aria-hidden="true" 包裹,避免屏幕阅读器把 “*” 读成无意义字符,而 “Required field” 这一关键语义通过 Tooltip 内容 + 可见标记共同表达。
高级实现:Floating UI 定位
当需要处理视口边缘翻转、偏移与箭头定位时,规则给出了基于 @floating-ui/react 的进阶版本:
import { useFloating, offset, flip, shift, arrow } from '@floating-ui/react'
import { useState, useRef, useId } from 'react'
interface AdvancedTooltipProps {
content: React.ReactNode
children: React.ReactElement
}
export function AdvancedTooltip({ content, children }: AdvancedTooltipProps) {
const [isOpen, setIsOpen] = useState(false)
const arrowRef = useRef(null)
const tooltipId = useId()
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef })
],
})
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
'aria-describedby': isOpen ? tooltipId : undefined,
onMouseEnter: () => setIsOpen(true),
onMouseLeave: () => setIsOpen(false),
onFocus: () => setIsOpen(true),
onBlur: () => setIsOpen(false),
})}
{isOpen && (
<div
ref={refs.setFloating}
id={tooltipId}
role="tooltip"
style={floatingStyles}
className="tooltip"
>
{content}
<div ref={arrowRef} className="tooltip-arrow" />
</div>
)}
</>
)
}
四个中间件各司其职:
offset(8):Tooltip 与触发器保持 8px 间距,避免视觉贴合;flip():首选位置放不下时自动翻转(如顶部空间不足则移到下方);shift({ padding: 8 }):沿水平方向平移,防止 Tooltip 超出视口 8px 安全边距;arrow({ element: arrowRef }):计算箭头指向,保证箭头始终对准触发器中心。
ARIA 逻辑与基础版完全一致:aria-describedby 仅在 isOpen 时设置,focus/blur 对称控制开合。从源码结构看,context 变量虽被解构但在示例中未直接使用(例如未接入 useRole 派生的 aria-* 属性),实际项目接入时可按需利用 context 做焦点管理。
图标按钮 + Tooltip 模式
对“只有图标、没有文字”的按钮,规则给出独立组件示例:
interface IconButtonProps {
icon: React.ReactNode
label: string
onClick: () => void
tooltip?: string
}
export function IconButton({ icon, label, onClick, tooltip }: IconButtonProps) {
const tooltipId = useId()
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="icon-button-wrapper">
<button
type="button"
onClick={onClick}
aria-label={label}
aria-describedby={tooltip && showTooltip ? tooltipId : undefined}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
className="icon-button"
>
{icon}
</button>
{tooltip && showTooltip && (
<span id={tooltipId} role="tooltip" className="tooltip">
{tooltip}
</span>
)}
</div>
)
}
该模式的核心是 aria-label 与 Tooltip 的职责分层:aria-label={label} 提供按钮的无障碍名称(屏幕阅读器聚焦时首先朗读它),而 aria-describedby 只在 Tooltip 显示时补充额外描述(如快捷键提示)。这与原文档“Tooltip vs 其他模式”表格中 “Icon-only button → aria-label + optional tooltip” 的结论一致。
原生 title 属性的局限与升级路径
原文档给出了“原生 title → 自定义 Tooltip”的对比:
<!-- Simple but limited accessibility -->
<button type="button" title="Save document">
<svg aria-hidden="true"><!-- save icon --></svg>
<span class="sr-only">Save</span>
</button>
<!-- Better: Custom tooltip with full control -->
<button
type="button"
aria-describedby="save-tooltip"
aria-label="Save"
>
<svg aria-hidden="true"><!-- save icon --></svg>
</button>
<div id="save-tooltip" role="tooltip" class="tooltip">
Save document (Ctrl+S)
</div>
title 属性的问题在于:出现时机由浏览器决定(通常数百毫秒延迟)、无法样式化、无法在键盘聚焦时可靠触发、无法保证与辅助技术的交互时序。自定义 Tooltip 则完全掌控显示时机、样式与 ARIA 关联。文档标注其为 “Limited”,意味着 title 只适合作为最低限度兜底,不应作为正式的提示方案。
完整 CSS 样式与关键细节
原文档附带了完整的样式实现,覆盖四向定位、箭头、动画、动效偏好与对比度,完整保留如下:
.tooltip-wrapper {
position: relative;
display: inline-block;
}
.tooltip {
position: absolute;
z-index: 1000;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
background: #1a1a1a;
color: #ffffff;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
/* Animation */
opacity: 0;
animation: tooltipFadeIn 0.15s ease-out forwards;
}
.tooltip--top {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
}
.tooltip--bottom {
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
}
.tooltip--left {
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 8px;
}
.tooltip--right {
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
}
/* Arrow */
.tooltip::after {
content: '';
position: absolute;
border: 6px solid transparent;
}
.tooltip--top::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: #1a1a1a;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
.tooltip {
animation: none;
opacity: 1;
}
}
/* Ensure sufficient contrast */
.tooltip {
/* WCAG requires 4.5:1 for normal text */
/* #ffffff on #1a1a1a = 16.1:1 ✓ */
}
样式中值得展开的四个细节:
pointer-events: none:Tooltip 不拦截鼠标事件,防止提示框挡在鼠标路径上引发 mouseleave 抖动闪烁——这正是实现要求表中 “Persistent” 的一条 CSS 手段;prefers-reduced-motion: reduce:为偏好减少动效的用户关闭淡入动画并直接以opacity: 1呈现,这是 WCAG 对动效可访问性的常见要求;- 对比度:
#ffffff于#1a1a1a对比度约 16.1:1,远超 WCAG 对普通文本 4.5:1 的门槛; white-space: nowrap+ 四向定位类:保证单行提示不折行,top/bottom/left/right四个修饰类通过100%偏移 +translateX/Y(-50%)实现居中定位。
Tooltip 与其他提示模式的选择
原文档给出了模式选择表,完整保留:
| Use Case | Pattern |
|---|---|
| Supplementary hint(补充性提示) | Tooltip |
| Essential instruction(必要说明) | Inline text(内联文本) |
| Complex content(复杂内容) | Popover/Dialog |
| Form field help(表单字段帮助) | aria-describedby text |
| Icon-only button(纯图标按钮) | aria-label + optional tooltip |
原则清晰:Tooltip 只承载“补充性”信息;必要说明必须以内联文本常显;复杂内容升级为 Popover 或 Dialog;表单字段帮助用静态的 aria-describedby 文本而非悬浮提示。
仓库自身如何落地该规则:Radix 设计系统
Front-End-Checklist 的 Web 应用(apps/web)本身就按这条规则实现了 Tooltip。设计系统封装位于 tooltip.tsx,基于 @radix-ui/react-tooltip(版本由 pnpm catalog 统一管理,见 design-system/package.json 中 "@radix-ui/react-tooltip": "catalog:" 依赖声明):
'use client'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@repo/utils'
import * as React from 'react'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md px-3 py-1.5',
'bg-foreground text-background',
'font-medium text-xs',
'fade-in-0 zoom-in-95 animate-in',
'data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
对照规则要求,这个封装印证了文档中的每个要点:
- Portal 渲染(
TooltipPrimitive.Portal):内容渲染到 body 层,规避祖先元素overflow裁剪; sideOffset = 4默认值:对应文档中offset(8)的间距思想,只是具体数值按设计系统密度调整为 4;data-[side=...]条件动画:按实际放置方位播放滑入动画,data-[state=closed]控制关闭动画——Radix 原语自动管理了 focus/hover 触发、Escape 关闭与aria-describedby关联,这些正是规则要求表中“Keyboard accessible / Dismissible / Programmatically associated”三条的库级实现;bg-foreground text-background语义色:以主题前景/背景色互换实现深色气泡,保证对比度跟随主题切换。
Web 端真实使用示例见 rule-checkbox.tsx:规则勾选框的卡片形态包裹在 TooltipProvider delayDuration={250} 中,TooltipTrigger asChild 把触发行为注入原生 <label>,TooltipContent side="bottom" 显示 “Mark complete / Mark incomplete / Saving...” 等补充性状态提示——正是文档“补充性提示用 Tooltip”原则的典型应用。其他使用点包括 checklist-action-bar.tsx(delayDuration={300})、segmented-progress-bar.tsx(delayDuration={200})、rules-browser-toolbar.tsx。从源码结构看,各页面按交互场景微调延迟(200~300ms),与文档中 delay = 300 的默认值设计意图一致。
七步验证清单
原文档的 Verification 章节列出了交付前的人工验证步骤,完整保留:
- Tab 到触发元素——Tooltip 应出现(验证 focus 触发);
- 按 Escape——Tooltip 应关闭(验证可关闭性);
- 鼠标悬停触发器——Tooltip 应在延迟后出现(验证 delay 与 hover 触发);
- 鼠标移入 Tooltip——应保持可见(验证 Persistent 要求);
- 使用屏幕阅读器测试(Tooltip 内容应被朗读);
- 确认 Tooltip 不遮挡其他内容;
- 检查颜色对比度满足 WCAG 要求。
这七步与要求表一一对应,可作为 PR 检查清单直接引用。
红线:不要在 Tooltip 中放关键信息
原文档以醒目警告收尾,此处同样必须强调:
绝不要把必要信息放进 Tooltip。它们只承载补充性提示。关键内容应当默认可见,或放在主界面中。
一条只在悬浮时才可见的提示,对键盘用户、触屏用户(无 hover 概念)和低视力用户都是事实上的“不存在”。评审代码时,凡发现把必要说明(如表单必填解释、错误原因、法律声明)藏进 Tooltip 的场景,都应要求改为内联文本。
延伸阅读与相关规则
- 本技能的 Agent 使用说明:SKILL.md
- 规则完整元数据(含 Check/Fix/Explain/Code Review 提示词与来源清单):accessible-tooltips.mdx
- 同一
html/components区域、常一起评审的相关规则:accessible-notifications、accordion-accessibility、carousel-accessibility、custom-element-accessibility(见 rules 内容目录)
规则文档在 frontmatter 中声明的权威参考为 MDN 的 HTML 文档与 WHATWG HTML Living Standard(标准规范),并推荐 Nu Html Checker(W3C 官方校验器)作为标记校验工具,以及 UX Patterns for Developers 的 Tooltip 模式指南作为交互设计参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00