普通视图

发现新文章,点击刷新页面。
昨天以前首页

写 HTML 就能做视频?HeyGen 开源的这个工具有点意思

作者 奇舞精选
2026年4月24日 20:34

HeyGen 开源了一个叫 HyperFrames 的框架,让你用 HTML、CSS 和 GSAP 来做视频。不是概念演示,是真能用的那种。


为什么要用代码做视频

用过 After Effects 或 Premiere 的人都知道,手动调关键帧是个力气活。做一个 10 秒的片头可能要调半小时,改个颜色又得重来一遍。项目文件是二进制格式,Git 根本管不了,团队协作基本靠 U 盘传文件。

HyperFrames 的思路很简单:既然前端开发都是写代码,视频为什么不能也写代码?HTML 定义元素,CSS 控制样式,GSAP 做动画,所有东西都是文本文件。Git 能管,改起来方便,批量生成写个脚本就行。配合 AI 的话,直接说"把标题改成从左边滑入",改完立刻能看效果。

这套东西适合谁用?如果你是做电影级特效,老老实实用 AE。但如果你是前端开发者,经常要做数据可视化、产品介绍视频、动态字幕这类东西,HyperFrames 能省不少事。

实现原理

HyperFrames 是一个四层架构,从上到下:

CLI (hyperframes render)
    ↓
Producer (@hyperframes/producer)   负责完整渲染流水线
    ↓
Engine (@hyperframes/engine)       负责帧捕获
    ↓
Core (@hyperframes/core)           提供运行时、类型、FrameAdapter

用户写 HTML,CLI 调 Producer,Producer 驱动 Engine 逐帧捕获,Core 负责页面内的时间轴控制。


核心机制:Seek-and-Capture 循环

HyperFrames 的做法: 不播放,只 seek。每一帧都是独立的静态快照:

for (let frame = 0; frame <= totalFrames; frame++) {
  const time = Math.floor(frame) / fps;  // 整数除法,无浮点误差
  await adapter.seekFrame(frame);         // 把动画拨到这一时刻
  // 捕获当前像素
}

时间计算用整数帧号除以 fps,不依赖任何系统时钟。


帧捕获:HeadlessExperimental.beginFrame

引擎启动的是 chrome-headless-shell(专为 CDP 控制优化的最小 Chrome 二进制),通过 Chrome DevTools Protocol 调用 HeadlessExperimental.beginFrame

这个 API 的作用是:显式命令合成器渲染一帧,并把像素 buffer 直接返回给调用方。效果是:

  • 没有"等渲染完成"的时序问题
  • 像素直接从 GPU 合成器取,不经过截图的 IPC 拷贝流程
  • 每帧是原子操作,不存在半渲染状态

FrameAdapter 协议:动画运行时的接入层

HyperFrames 不锁定任何动画库。它定义了一个 FrameAdapter 接口,任何能"按帧 seek"的东西都能接入:

type FrameAdapter = {
  id: string;
  init?: (ctx: FrameAdapterContext) => Promise<void> | void;
  getDurationFrames: () => number;       // 视频总帧数
  seekFrame: (frame: number) => void;    // 把动画拨到第 N 帧
  destroy?: () => void;
};

GSAP 的 adapter 实现大概是:

seekFrame(frame) {
  const time = frame / fps;
  gsap.globalTimeline.pause();
  gsap.globalTimeline.seek(time);   // 直接拨时间轴
}

seekFrame 必须是幂等的(同一帧调两次结果相同),且必须支持随机 seek(可以先 seek 第 90 帧再 seek 第 10 帧),不能有顺序依赖。


window.__hf 协议:引擎和页面的通信桥

引擎(Node.js 进程)和页面(浏览器内)之间通过 window.__hf 对象通信:

interface HfProtocol {
  duration: number;          // 视频总时长(秒)
  seek(time: number): void;  // 引擎调这个来驱动帧 seek
  media?: HfMediaElement[];  // 音视频元素声明(给引擎做音频抽取用)
}

页面加载完成后,Core 注入的运行时把自己挂在 window.__hf 上。引擎每帧调 page.evaluate(() => window.__hf.seek(t)),页面内的 FrameAdapter 响应,GSAP 时间轴被拨到对应位置,然后引擎立刻调 beginFrame 捕获。

任何实现了这个协议的页面都能被引擎渲染,不局限于 HyperFrames 格式的 HTML。


音频处理:单独抽取,最后混合

浏览器渲染是纯视觉的,音频不能从帧里捕获。Producer 的做法是把音频流程完全分离:

  1. 解析 HTML 里的 <audio><video> 元素,读取 data-startdata-durationdata-volume 等属性
  2. 用 FFmpeg 从源文件里单独提取音轨,按时间轴剪切、调音量
  3. 所有音轨混合成一个主音轨
  4. 视频帧编码完成后,再用 FFmpeg 把视频和音轨 mux 到一起

并行渲染

单个 Engine session 是串行的(一帧一帧 seek),但 Producer 会开多个 session 并行:

calculateOptimalWorkers(totalFrames)  // 根据 CPU 核数算出最优 worker 数
distributeFrames(totalFrames, workers) // 把帧分段,每个 worker 负责一段
executeParallelCapture(tasks)          // 并行跑,各 worker 独立开 Chrome 实例

每个 worker 是完全独立的 capture session,有自己的 Chrome 进程和页面实例,不共享状态。最后按帧序号合并,送给 FFmpeg 编码。


确定性保证

同一份 HTML,任意时间在任意机器上渲染,输出的 MP4 应该二进制相同(Docker 模式下严格成立)。这靠几件事保证:

  • 时间用 Math.floor(frame) / fps 计算,不用 Date.now()
  • seekFrame 幂等且无顺序依赖
  • 所有资源在渲染前必须加载完(有 __renderReady readiness gate)
  • 禁止 Math.random()(无 seed)
  • Chrome 版本固定(Docker 模式下完全锁定)

本地渲染可能因系统字体和 Chrome 小版本差异有微小像素差异,Docker 模式消除这个问题。


完整流程图

npx hyperframes render
        │
        ▼
CLI → Producer
        │
        ├─► 解析 HTML,提取音视频元素
        │
        ├─► 启动 File Server(HTTP 本地服务,给 Chrome 加载文件用)
        │
        ├─► 启动 N 个 worker(每个 worker 一个 Chrome 实例)
        │        │
        │        ▼
        │   initializeSession(html)
        │        │
        │        ├─► 注入 Core 运行时(挂 window.__hf)
        │        │
        │        └─► for each frame:
        │               window.__hf.seek(t)   ← GSAP timeline.seek(t)
        │               HeadlessExperimental.beginFrame
        │               → pixel buffer
        │
        ├─► pixel buffer → FFmpeg → video.mp4(无音频)
        │
        ├─► 音频抽取 → 混合 → audio.wav
        │
        └─► FFmpeg mux(video.mp4 + audio.wav) → output.mp4

安装

npx hyperframes init my-video

项目结构

my-video/
├── index.html          # 主时间轴文件
├── meta.json           # 项目元数据(id, name)
├── hyperframes.json    # 路径配置
├── narration.wav       # 音频文件(可选)
├── transcript.json     # 转录文件(可选)
├── compositions/       # 子组件目录
│   └── intro.html
└── assets/             # 静态资源
    ├── images/
    └── fonts/

核心概念

1. 时间轴声明

用 data 属性定义时间:

<div 
  class="clip"
  data-start="0" 
  data-duration="5" 
  data-track-index="1"
>
  <h1>Hello World</h1>
</div>

必须的三个属性:

  • data-start: 开始时间(秒)
  • data-duration: 持续时长(秒)
  • data-track-index: 图层索引(类似 AE)

注意:有时间属性的元素必须加 class="clip",框架用它控制显示。

2. GSAP 动画

// 创建并注册时间轴
var tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;

// 添加动画
tl.from(".title", {
  y: 100,        // 从下方 100px 进入
  opacity: 0,    // 从透明到不透明
  duration: 1.0,
  ease: "power3.out"
}, 0.2);  // 在 0.2 秒处开始

常用缓动函数:

  • power2.out - 快入慢出
  • power3.out - 更强烈的快入慢出
  • back.out(1.7) - 回弹效果
  • elastic.out - 弹性效果

3. 字幕同步

var GROUPS = [
  { id: "cg-0", start: 0.5, end: 2.0 },
  { id: "cg-1", start: 2.2, end: 3.8 }
];

GROUPS.forEach(function(group) {
  var el = document.getElementById(group.id);
  
  // 入场
  tl.fromTo(el, 
    { opacity: 0, visibility: "visible" },
    { opacity: 1, duration: 0.3 },
    group.start
  );
  
  // 退场
  tl.to(el, { opacity: 0 }, group.end - 0.15);
  tl.set(el, { visibility: "hidden" }, group.end);
});

效果展示

我做了个智能手表的产品介绍视频,14 秒,三个场景。

智能手表产品介绍

三个场景的安排:

  • 场景 1(0-4s):产品名 + 价格,用了 back.out 回弹效果
  • 场景 2(4-10s):三张功能卡片,stagger 交错出现
  • 场景 3(10-14s):CTA 按钮,elastic.out 弹性动画

下面拆开看看每个场景怎么写的。

场景 1:产品展示

// 产品名称从下方弹入
tl.from(" .product-name", {
  y: 100, opacity: 0, duration: 0.8, ease: "power3.out"
}, 0.3);

// 价格放大淡入(带回弹)
tl.from(" .price", {
  scale: 0, opacity: 0, duration: 0.6, ease: "back.out(1.7)"
}, 1.2);

场景 2:功能卡片

// 三张卡片交错出现
tl.from(" .feature-card", {
  y: 60, 
  opacity: 0, 
  duration: 0.5,
  stagger: 0.2  // 关键:每张间隔0.2秒
}, 4.8);

CSS 毛玻璃效果:

.feature-card {
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  border: 2px solid rgba(255, 255, 255, 0.2);
}

场景 3:CTA按钮

// 按钮弹入
tl.from(" .cta-button", {
  scale: 0, opacity: 0, duration: 0.6, ease: "elastic.out(1, 0.5)"
}, 11.0);

// 脉冲动画(吸引点击)
tl.to(" .cta-button", {
  scale: 1.1, duration: 0.3, repeat: 3, yoyo: true
}, 11.8);

总结

HyperFrames 的核心思路就是把视频当代码管。对前端开发者来说,这套东西上手很快,HTML、CSS、GSAP 都是熟悉的技术栈。

不过也别指望它能做电影级特效。毕竟是基于浏览器渲染的,复杂的 3D 动画、粒子效果这些做不了。但对于产品介绍、数据可视化、字幕动画这类需求,够用了。

构建无障碍组件之Window Splitter Pattern

作者 anOnion
2026年4月19日 22:02

Window Splitter Pattern 详解:构建可拖拽面板分割器

Window Splitter(窗口分割器,也称为 Resizable SplitterPane ResizerSplit PanelDivider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。

一、Window Splitter 的定义与核心概念

1.1 什么是 Window Splitter

Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:

  • 位于两个面板之间,作为可交互的分隔线
  • 支持拖拽调整面板大小
  • 可以是**可变(variable)固定(fixed)**类型
    • 可变分割器:可以在允许范围内调整到任意位置
    • 固定分割器:在两个固定位置之间切换
  • 具有表示**主面板(primary pane)**大小的数值

1.2 核心术语

术语 说明
Primary Pane 主面板,分割器的值表示该面板的大小
Secondary Pane 次面板,大小随主面板变化而调整
Variable Splitter 可变分割器,可在范围内任意调整
Fixed Splitter 固定分割器,只能在两个位置间切换
Value 分割器当前值,表示主面板的大小(通常为 0-100)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────┬──────────────────────────────────────┐    │
│  │                  │                                      │    │
│  │   Primary Pane   │          Secondary Pane              │    │
│  │                  │                                      │    │
│  │  ┌────────────┐  │  ┌────────────────────────────────┐  │    │
│  │  │            │  │  │                                │  │    │
│  │  │  Content   │  │  │         Content                │  │    │
│  │  │            │  │  │                                │  │    │
│  │  └────────────┘  │  └────────────────────────────────┘  │    │
│  │                  │                                      │    │
│  └──────────────────┼──────────────────────────────────────┘    │
│                     │                                           │
│              ┌──────┴──────┐                                    │
│              │  Splitter   │  <-- draggable separator           │
│              │  (separator)│      role="separator"              │
│              └─────────────┘      aria-valuenow                 │
│                                                                 │
│  Value = 30 (Primary: 30%, Secondary: 70%)                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。

1.3 典型应用场景

  • 代码编辑器:左侧文件树,右侧代码编辑区
  • 阅读应用:左侧目录,右侧正文内容
  • 邮件客户端:左侧邮件列表,右侧邮件详情
  • 设计工具:左侧工具栏,右侧画布

二、WAI-ARIA 角色与属性

2.1 基本角色

Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)

<div
  role="separator"
  aria-label="目录"
  aria-valuenow="30"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-controls="primary-pane"
  tabindex="0">
</div>

2.2 必需属性

属性 说明 示例值
role="separator" 标记为分隔符角色 -
aria-valuenow 当前值,表示主面板大小 "30"
aria-valuemin 最小值,主面板最小时的位置 "0"
aria-valuemax 最大值,主面板最大时的位置 "100"
aria-controls 指向主面板元素 "primary-pane"
aria-labelaria-labelledby 可访问标签,应与主面板名称匹配 "目录"

2.3 属性详解

aria-valuenow

表示分割器的当前位置,通常映射为主面板的百分比大小:

  • 0:主面板完全折叠(最小)
  • 100:主面板完全展开(最大)
  • 30:主面板占 30%,次面板占 70%
aria-controls

指向主面板元素,让辅助技术知道分割器控制哪个面板:

<div id="primary-pane" role="region" aria-label="目录">
  <!-- 主面板内容 -->
</div>

<div
  role="separator"
  aria-controls="primary-pane"
  ...>
</div>
aria-label

标签应与主面板名称匹配,帮助用户理解分割器的作用:

<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
← Left Arrow 垂直分割器向左移动
→ Right Arrow 垂直分割器向右移动
↑ Up Arrow 水平分割器向上移动
↓ Down Arrow 水平分割器向下移动
Enter 切换主面板的展开/折叠状态
Home(可选) 将分割器移到最小位置(可能完全折叠主面板)
End(可选) 将分割器移到最大位置(可能完全展开主面板)
F6(可选) 在窗口面板之间循环切换焦点

3.2 Enter 键行为详解

Enter 键用于切换主面板的折叠状态

  • 如果主面板未折叠:折叠主面板(分割器移到最小值)
  • 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
  const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
  const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
  
  if (currentValue > minValue) {
    // 主面板未折叠,保存当前位置并折叠
    splitter.dataset.previousValue = currentValue;
    setSplitterValue(splitter, minValue);
  } else {
    // 主面板已折叠,恢复到之前的位置
    const previousValue = parseInt(splitter.dataset.previousValue || '50');
    setSplitterValue(splitter, previousValue);
  }
}

3.3 固定分割器的键盘交互

固定分割器只支持 Enter 键,不支持方向键:

  • 在两个固定位置之间切换
  • 例如:折叠/展开侧边栏

四、鼠标交互规范

4.1 拖拽行为

  • 鼠标按下:开始拖拽,记录起始位置
  • 鼠标移动:实时更新分割器位置和面板大小
  • 鼠标释放:结束拖拽,保存最终位置

4.2 视觉反馈

  • 悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为 col-resizerow-resize
  • 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
  • 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
  cursor: col-resize; /* 垂直分割器 */
}

[role="separator"][aria-orientation="horizontal"] {
  cursor: row-resize; /* 水平分割器 */
}

[role="separator"]:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

五、实现方式

5.1 基础 Window Splitter 结构

<!-- 窗口容器 -->
<div class="window-container">
  <!-- 主面板 -->
  <div
    id="primary-pane"
    class="primary-pane"
    role="region"
    aria-label="目录">
    <!-- 主面板内容 -->
    <nav>
      <h2>目录</h2>
      <ul>
        <li><a href="#ch1">第一章</a></li>
        <li><a href="#ch2">第二章</a></li>
      </ul>
    </nav>
  </div>

  <!-- 分割器 -->
  <div
    role="separator"
    class="splitter"
    aria-label="目录"
    aria-valuenow="30"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-controls="primary-pane"
    tabindex="0">
  </div>

  <!-- 次面板 -->
  <div
    class="secondary-pane"
    role="region"
    aria-label="内容">
    <!-- 次面板内容 -->
    <article>
      <h1>文章标题</h1>
      <p>文章内容...</p>
    </article>
  </div>
</div>

5.2 CSS 样式

.window-container {
  display: flex;
  height: 100vh;
}

.primary-pane {
  width: 30%; /* 初始宽度对应 aria-valuenow="30" */
  min-width: 0;
  overflow: auto;
}

.splitter {
  width: 4px;
  background-color: #e5e7eb;
  cursor: col-resize;
  transition: background-color 0.2s;
}

.splitter:hover,
.splitter:focus {
  background-color: #3b82f6;
}

.splitter:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.secondary-pane {
  flex: 1;
  overflow: auto;
}

5.3 JavaScript 实现

class WindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    this.container = this.splitter.parentElement;
    
    this.isDragging = false;
    this.startX = 0;
    this.startWidth = 0;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
    document.addEventListener('mouseup', this.handleMouseUp.bind(this));
    
    // 键盘事件
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseDown(e) {
    this.isDragging = true;
    this.startX = e.clientX;
    this.startWidth = this.primaryPane.offsetWidth;
    this.container.style.userSelect = 'none';
  }

  handleMouseMove(e) {
    if (!this.isDragging) return;
    
    const delta = e.clientX - this.startX;
    const newWidth = this.startWidth + delta;
    const containerWidth = this.container.offsetWidth;
    const percentage = Math.round((newWidth / containerWidth) * 100);
    
    this.setValue(percentage);
  }

  handleMouseUp() {
    this.isDragging = false;
    this.container.style.userSelect = '';
  }

  handleKeyDown(e) {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    const step = 5; // 每次移动 5%

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        this.setValue(Math.max(minValue, currentValue - step));
        break;
      case 'ArrowRight':
        e.preventDefault();
        this.setValue(Math.min(maxValue, currentValue + step));
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(minValue);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(maxValue);
        break;
      case 'Enter':
        e.preventDefault();
        this.toggleCollapse();
        break;
    }
  }

  setValue(value) {
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    
    // 限制在范围内
    value = Math.max(minValue, Math.min(maxValue, value));
    
    // 更新 ARIA 属性
    this.splitter.setAttribute('aria-valuenow', value);
    
    // 更新视觉
    this.primaryPane.style.width = value + '%';
  }

  toggleCollapse() {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    
    if (currentValue > minValue) {
      // 保存当前值并折叠
      this.splitter.dataset.previousValue = currentValue;
      this.setValue(minValue);
    } else {
      // 恢复之前的位置
      const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
      this.setValue(previousValue);
    }
  }
}

// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);

5.4 固定分割器实现

固定分割器只支持 Enter 键切换:

class FixedWindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    
    this.positions = [0, 30]; // 两个固定位置:折叠、展开
    this.currentIndex = 1; // 默认展开
    
    this.init();
  }

  init() {
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.togglePosition();
    }
  }

  togglePosition() {
    this.currentIndex = (this.currentIndex + 1) % this.positions.length;
    const value = this.positions[this.currentIndex];
    
    this.splitter.setAttribute('aria-valuenow', value);
    this.primaryPane.style.width = value + '%';
  }
}

六、最佳实践

6.1 提供清晰的标签

分割器的标签应与主面板名称匹配:

<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>

6.2 确保键盘可访问

  • 分割器必须可聚焦(tabindex="0"
  • 支持方向键调整位置
  • 支持 Enter 键折叠/展开

6.3 提供视觉反馈

  • 悬停时改变光标样式
  • 焦点状态清晰可见
  • 拖拽过程中实时更新面板大小

6.4 限制调整范围

设置合理的 aria-valueminaria-valuemax,防止面板过小或过大:

<!-- 主面板最小 15%,最大 50% -->
<div
  role="separator"
  aria-valuemin="15"
  aria-valuemax="50"
  ...>
</div>

6.5 保存用户偏好

记住用户调整后的面板大小,下次访问时恢复:

// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));

// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
  splitter.setAttribute('aria-valuenow', savedValue);
  primaryPane.style.width = savedValue + '%';
}

6.6 响应式设计考虑

在小屏幕上,考虑禁用分割器或提供替代方案:

@media (max-width: 768px) {
  [role="separator"] {
    display: none; /* 小屏幕隐藏分割器 */
  }
  
  .primary-pane {
    width: 100% !important; /* 全宽显示 */
  }
}

七、常见错误

7.1 忘记设置 aria-controls

<!-- 错误 -->
<div role="separator" aria-label="目录"></div>

<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>

7.2 标签与主面板不匹配

<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>

<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>

7.3 忽略键盘交互

只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。

八、总结

构建无障碍的 Window Splitter 组件需要关注:

  1. 正确的角色:使用 role="separator"
  2. 必需的属性aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label
  3. 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
  4. 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
  5. 清晰的标签:标签与主面板名称匹配
  6. 视觉反馈:悬停、焦点、拖拽状态的视觉提示

遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

❌
❌