跳到主要内容

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
缩小后手柄点不中热区跟了世界像素

权威资料

核对日期:2026-08-26