Canvas无障碍
<canvas> 对辅助技术几乎是一张图。里面的字不可选、不可读、没有角色、没有焦点环(除非你自己接)。交互式 Canvas 必须有平行的 DOM 语义树;Canvas 只负责像素。不要指望「把名字写进 canvas 后备内容」就能过 WCAG。
1. 后备内容不是无障碍树
<canvas width="800" height="600">
你的浏览器不支持 canvas。
</canvas>
标签之间的 DOM 只在 不支持 canvas 时给用户看。现代浏览器会画 canvas,读屏通常 不会 把里面的文字当可访问内容。产品文案不要写在这里当 a11y 方案。
静态图(图表导出预览、签名展示):
<canvas role="img" aria-label="2026 年 Q3 销售额柱状图,华东最高"></canvas>
有信息的图还要提供 同等文本:旁边的 HTML 表格或 aria-describedby 指向数据摘要。只靠一张 label 不够复杂图。
2. 交互式:影子 UI 在 DOM
编辑器、白板、游戏 HUD,可访问对象是 DOM,不是 path。
export interface A11yItem {
id: string;
role: "button" | "img" | "listitem";
name: string;
disabled?: boolean;
}
export function syncA11yList(
host: HTMLElement,
canvas: HTMLCanvasElement,
items: readonly A11yItem[],
focusedId: string | undefined,
onActivate: (id: string) => void,
): void {
host.replaceChildren();
const list = document.createElement("ul");
list.id = "canvas-a11y";
canvas.setAttribute("aria-controls", list.id);
canvas.setAttribute("role", "application");
canvas.setAttribute("aria-label", "画布");
for (const item of items) {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = item.name;
btn.disabled = item.disabled === true;
btn.addEventListener("click", () => onActivate(item.id));
if (item.id === focusedId) btn.setAttribute("data-canvas-focus", "true");
li.append(btn);
list.append(li);
}
host.append(list);
}
视觉上可以把 #canvas-a11y 做成屏幕外(clip/visually-hidden),但 必须能 Tab 到,且键盘激活要和指针选中同一套 scene 命令。不要 display: none(部分 AT 会忽略)。
更稳的模式:侧栏本来就是对象列表(标注工具都有),直接让那份 DOM 当无障碍入口,canvas aria-hidden="true" 避免双读。画布仅鼠标/触控;键盘走列表。这比假 role=application 少踩坑。
role="application" 会劫持读屏快捷键,只用在确实把画布当应用、且你提供了完整键盘图的时候。默认别加。
3. 焦点环:drawFocusIfNeeded
规范提供把 当前聚焦元素 的焦点环画进 canvas 路径:
export function paintFocusRing(
ctx: CanvasRenderingContext2D,
path: Path2D,
focusTarget: Element,
): void {
if (document.activeElement !== focusTarget) return;
ctx.save();
ctx.strokeStyle = "transparent";
ctx.drawFocusIfNeeded(path, focusTarget);
ctx.restore();
}
focusTarget 必须是可聚焦 DOM(侧栏按钮、overlay 上的 tabbable)。路径用 当前 CTM 下与物体一致 的 path,否则环漂在别处。系统高对比度主题下,浏览器画的环比你手绘 2px 蓝框可靠。
没有对应 DOM 节点就不要调用:环不属于任何焦点,键盘用户仍会丢。
4. 命中、键盘、读屏走同一选中 id
export function keyboardMove(
items: readonly { id: string }[],
focusedId: string | undefined,
key: "ArrowNext" | "ArrowPrev",
): string | undefined {
if (items.length === 0) return undefined;
const i = items.findIndex((it) => it.id === focusedId);
const start = i < 0 ? 0 : i;
const delta = key === "ArrowNext" ? 1 : -1;
const next = (start + delta + items.length) % items.length;
return items[next]?.id;
}
指针命中改 focusedId 时,同步 button.focus(),否则读屏焦点和视觉选中分裂。反向:列表 focus 时滚动相机让该物体进视口。
5. 文本与动态字
- IME / 选区 / 复制:textarea overlay,见 Overlay。
- 图表数字:DOM tooltip + 数据表。
- 游戏伤害数字:装饰性可用 canvas;任务目标必须 DOM 或
aria-live。
aria-live="polite" 区域报「已添加矩形」,不要每帧报坐标。
6. 感知与运动
export function preferReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
减少相机动画、闪烁选区、行军蚁。对比度:不要只靠红绿区分状态,叠加形状/线型。触控热区在 view 空间 ≥ 44 CSS 像素,手柄尺寸 max(44, 8) / camera.scale 的世界边长要换算回去。
7. 适用 / 不适用
适用:任何可点、可选、可读信息的 canvas 产品。
不适用:纯装饰背景(可 aria-hidden)、已有完整 SVG/DOM 的图还再叠一层 canvas 抢焦点。
WCAG 对非文本内容、键盘、焦点可见、名称角色值的要求,Canvas 不会豁免。
8. 失败形态
| 症状 | 原因 |
|---|---|
| 读屏只说「画布空白」 | 没有平行 DOM / label |
| 后备 HTML 以为能被读到 | 当成了 a11y 树 |
| 焦点环错位 | path 与 CTM 和物体不一致 |
| Tab 进画布出不来 | application 角色 + 没做 Tab 环 |
| 键盘选中和鼠标选中不是同一个 | 两份 focus state |
| 缩小后手柄点不中 | 热区跟了世界像素 |
权威资料
- HTML — The canvas element(fallback)
- drawFocusIfNeeded()
- WAI-ARIA Authoring Practices
- WCAG 2.2
- MDN — Canvas accessibility(hit region API 已废弃,不要用;改 DOM 平行树)
核对日期:2026-08-26