首页
/ Front-End-Checklist 实战指南:构建可访问的 Tooltip(可访问性提示框)——ARIA 关联、键盘交互与仓库设计系统实现

Front-End-Checklist 实战指南:构建可访问的 Tooltip(可访问性提示框)——ARIA 关联、键盘交互与仓库设计系统实现

2026-09-04 23:11:53作者:宗隆裙

本篇基于 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>
  )
}

逐点解析关键设计:

  1. useId() 生成唯一 idtooltipId 用于 idaria-describedby 配对,避免多实例时 id 冲突——手写 tooltip-1 这类静态 id 在列表渲染场景下会产生重复 id,破坏 ARIA 关联。
  2. cloneElement 注入行为与属性:触发器的 onMouseEnter/onMouseLeave/onFocus/onBlur 四个事件和 aria-describedby 都是在克隆子元素时动态附加的。aria-describedby 仅在 isVisible 时设置,Tooltip 隐藏时移除关联,避免屏幕阅读器朗读一个不存在的节点。
  3. delay 延迟显示(默认 300ms):hover 显示通过 setTimeout 延迟,hideTooltip 会先 clearTimeout,防止鼠标短暂划过时 Tooltip 频繁闪现。这个延迟策略正是仓库 Web 端实际采用的参数思路——后文会看到 rule-checkbox.tsx 使用 delayDuration={250}checklist-action-bar.tsx 使用 delayDuration={300}
  4. Escape 键关闭useEffect 中注册全局 keydown 监听,isVisible 变化时正确挂载/卸载监听器,Escape 时调用 hideTooltip()。这满足要求表中“Dismissible”一项。
  5. 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 ✓ */
}

样式中值得展开的四个细节:

  1. pointer-events: none:Tooltip 不拦截鼠标事件,防止提示框挡在鼠标路径上引发 mouseleave 抖动闪烁——这正是实现要求表中 “Persistent” 的一条 CSS 手段;
  2. prefers-reduced-motion: reduce:为偏好减少动效的用户关闭淡入动画并直接以 opacity: 1 呈现,这是 WCAG 对动效可访问性的常见要求;
  3. 对比度#ffffff#1a1a1a 对比度约 16.1:1,远超 WCAG 对普通文本 4.5:1 的门槛;
  4. 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.tsxdelayDuration={300})、segmented-progress-bar.tsxdelayDuration={200})、rules-browser-toolbar.tsx。从源码结构看,各页面按交互场景微调延迟(200~300ms),与文档中 delay = 300 的默认值设计意图一致。

七步验证清单

原文档的 Verification 章节列出了交付前的人工验证步骤,完整保留:

  1. Tab 到触发元素——Tooltip 应出现(验证 focus 触发);
  2. 按 Escape——Tooltip 应关闭(验证可关闭性);
  3. 鼠标悬停触发器——Tooltip 应在延迟后出现(验证 delay 与 hover 触发);
  4. 鼠标移入 Tooltip——应保持可见(验证 Persistent 要求);
  5. 使用屏幕阅读器测试(Tooltip 内容应被朗读);
  6. 确认 Tooltip 不遮挡其他内容;
  7. 检查颜色对比度满足 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 模式指南作为交互设计参考。

登录后查看全文
热门项目推荐
相关项目推荐