Design System: 动态字符数码与鼠标扰动

一套将动态影像实时转换为字符矩阵,并让字符在鼠标经过时产生稀疏数码余波的交互语言。字符层是持续变化的环境介质,不是正文、代码雨或覆盖内容的特效。

参考交互:Essential。本文提取的是可复用的视觉机制、采样算法和鼠标响应规则,不绑定原网站的页面背景、影像内容或品牌配色。

1. 核心体验

动态字符层由两个系统叠加组成:

  1. 环境字符动画: 隐藏的动态影像被降低到字符网格分辨率,每个采样格根据亮度映射成不同密度的符号。
  2. 鼠标局部扰动: 鼠标经过时,附近少量字符暂时脱离影像映射,快速经历“密集字符 → 稀疏字符 → 空格”的离散变化,然后恢复为当前影像对应的字符。

最终效果应表现为:

  • 远看像低对比度、持续呼吸的抽象数字影像。
  • 近看能辨认出数字、字母和符号组成的字符矩阵。
  • 鼠标移动会留下短暂、稀疏的数码扰动。
  • 鼠标停止后不会持续形成圆形光斑或吸附效果。
  • 字符层永远服从内容层级,不与标题、按钮和媒体争夺注意力。

2. 视觉语言

字符气质

  • 使用等宽字体建立稳定网格。
  • 字符组合同时包含高密度符号、数字、字母和轻标点。
  • 字符不是随机噪声;默认状态由底层影像亮度决定。
  • 动态来自字符替换,而不是单个字符的位移、旋转或缩放。
  • 字符矩阵应保持轻、薄、低对比,像数字材料而不是终端界面。

推荐字符梯度

字符按视觉墨量从重到轻排列:

#N$976543210?!abcxyz;:+=-_,. 
  • 左侧字符更密、更黑。
  • 右侧字符更轻,最后一位为空格。
  • 暗像素映射到高密度字符。
  • 亮像素映射到轻字符或空格。
  • 可以替换字符内容,但必须保持“由密到疏”的视觉顺序。
  • 不使用 emoji、汉字、多字节图标或宽度不稳定的 Unicode 符号。

3. 字符网格

参考尺寸:

.ascii-field {
  font-family: "Geist Mono", ui-monospace, monospace;
  font-size: 18px;
  line-height: 18px;
  letter-spacing: 6px;
  white-space: pre;
}

网格参数

  • 行高 / Cell Y: 18px
  • 字形宽度: 运行时测量等宽字体字符 M 的实际宽度。
  • 列步长 / Cell X: 字形宽度 + 6px 字距。
  • 列数:
cols = Math.floor(
  (viewportWidth + letterSpacing) / cellX
);
  • 行数:
rows = Math.floor(viewportHeight / cellY);

参考页面在 1440 × 1000 视口中形成约 86 × 55 的网格,即每帧约 4,700 个字符。

字体加载

在计算网格前等待等宽字体可用:

await document.fonts.load('1em "Geist Mono"');

随后用一个不可见的测量元素计算真实字宽。不要假定 font-size 等于字符宽度,否则横向采样会偏移。

4. 图层结构

<div class="ascii-layer" aria-hidden="true">
  <pre class="ascii-field"></pre>
</div>

推荐样式:

.ascii-layer {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  user-select: none;
}

.ascii-field {
  width: 100%;
  height: 100%;
  margin: 0;
  white-space: pre;
}
  • 字符层铺满当前视口,而不是整张长页面。
  • 页面滚动时字符层保持固定,动态源可随章节切换。
  • 字符层不得拦截点击、悬停或文本选择。
  • 推荐在独立 stacking context 中放在内容层之后。
  • 谨慎使用负 z-index;如果父级没有正确隔离,字符层可能掉到页面背景之后而不可见。

5. 动态影像到字符

动态源

动态源可以是:

  • 低帧率循环视频。
  • 程序生成的噪声、粒子或流体场。
  • 动态渐变与蒙版。
  • 摄像机或实时可视化数据。
  • 多张灰度图之间的缓慢过渡。

动态源不直接显示。它只负责向字符网格提供每个单元的亮度。

离屏采样

创建一个与字符网格同分辨率的离屏 Canvas:

offscreen.width = cols;
offscreen.height = rows;

把动态源按 cover 逻辑绘制到该 Canvas。绘制时必须考虑字符格通常不是正方形:

cellAspect = cellX / cellY;

如果忽略字符格宽高比,原始影像会在字符矩阵中被横向或纵向拉伸。

亮度计算

每个 Canvas 像素映射到一个字符:

const luminance =
  (54 * red + 183 * green + 18 * blue) >> 8;

const index = Math.round(
  luminance * (ramp.length - 1) / 255
);

const character = ramp[index];

这是一种接近感知亮度的整数近似,绿色权重最高,蓝色最低。

Alpha 规则

如果采样像素透明:

character = " ";

不要将透明区域映射成点号,否则动态源的矩形边界会被暴露。

更新 DOM

逐行拼接完整字符串,一帧只执行一次:

pre.textContent = frameText;

不要为每个字符创建 <span>,也不要让 React/Vue 每帧协调数千个节点。

6. 帧率与基础动态

推荐字符层以 24fps 更新:

const FRAME_TIME = 1000 / 24; // 41.667ms

使用 requestAnimationFrame,但只有累计时间达到帧预算时才重新采样和更新文本。

  • 24fps 能保留数字媒介的离散感。
  • 不需要追求 60fps;过于顺滑会削弱字符动画的机械质感。
  • 底层视频可以正常播放,字符采样单独限帧。
  • 页面不可见时暂停更新。
  • 页面恢复时修正暂停时间,避免动画进度瞬间跳跃。

7. 鼠标扰动区域

指针到网格

将鼠标视口坐标转换为字符格坐标:

const localX = event.clientX - fieldRect.left;
const localY = event.clientY - fieldRect.top;

const pointerCol = Math.floor(localX / cellX);
const pointerRow = Math.floor(localY / cellY);

所有坐标都需要限制在有效网格范围内。

影响半径

参考半径为 6 个字符格:

for (let dy = -6; dy <= 6; dy++) {
  for (let dx = -6; dx <= 6; dx++) {
    if (dx * dx + dy * dy > 36) continue;
  }
}

这是逻辑圆,不绘制可见圆形边界。

稀疏激活

半径内的每个单元只有约 5% 的概率被激活:

if (Math.random() > 0.05) continue;

半径 6 的圆内约有一百多个候选格,因此每次 mousemove 通常只激活约 5–6 个字符。

这种稀疏性非常重要:

  • 它让响应看起来像局部数码故障,而不是鼠标光圈。
  • 连续移动会自然累积成断续余波。
  • 静止指针不会持续生成动画。
  • 不需要绘制鼠标尾巴或独立粒子。

8. 单个字符的扰动生命周期

每个被激活的字符保存:

type ActiveCell = {
  startedAt: number;
  duration: number;
  sequence: string[];
};

生命周期时长

duration = 500 * random(0.8, 1.4);

即约 400–700ms

字符序列

从字符梯度中随机选择三个互不相同的索引,再加入最后的空格索引,并按从密到疏排序:

重字符 → 中字符 → 轻字符 → 空格

把生命周期平均分成四段:

phase = Math.min(
  3,
  Math.floor(4 * elapsed / duration)
);

character = sequence[phase];

因此字符不是高速无序闪烁,而是在四个离散阶段中逐渐“溶解”。

生命周期结束后:

character = sampledVideoCharacter;

即恢复为当前动态影像在该位置应有的字符。

重复激活

如果某个字符仍处于当前扰动生命周期,不要立即重新触发。这样可以避免鼠标缓慢移动时同一位置不停重置动画。

9. 鼠标交互行为

推荐监听:

window.addEventListener("mousemove", onMouseMove, {
  passive: true
});
  • 交互层覆盖整个视口,因此监听 window 比监听局部元素稳定。
  • 只根据真实移动事件生成扰动。
  • 不读取鼠标速度控制亮度或半径。
  • 快速移动会因事件密度增加而形成更长但仍稀疏的轨迹。
  • 慢速移动形成局部、更细的字符扰动。
  • 指针离开页面后无需继续生成动画。
  • 不替换系统鼠标,不绘制自定义光标。

触控设备

移动端保留自动字符动画即可。可选的触摸响应:

  • pointerdown 时触发一次局部扰动。
  • 不跟随手指持续生成高密度轨迹。
  • 不阻止页面滚动。
  • 不把鼠标 hover 逻辑强行映射为长按。

10. 滚动章节与动态源切换

页面可以为不同章节声明不同动态源:

<section data-ascii-source="1"></section>
<section data-ascii-source="2"></section>

系统按 offsetTop 排序章节,并根据滚动位置选择当前动态源。

提前切换

当下一章节距离视口顶部不足约半个视口高度时,可以提前开始切换:

nextSection.top - scrollY < 0.5 × viewportHeight

这样字符背景会在内容进入视觉中心前完成语境变化。

源之间过渡

保留当前源和前一个源,在离屏 Canvas 中交叉绘制:

draw(previousSource, 1 - progress);
draw(currentSource, progress);
  • 推荐过渡时长 750ms
  • 只交叉淡化动态源,不对整个 <pre> 修改 opacity。
  • 过渡结束后释放前一个源引用。
  • 所有视频提前加载、静音、循环并使用 playsInline

11. 配色与背景关系

动态字符系统不规定固定页面背景,只要求字符层处于低对比环境层级。

浅色背景

  • 字符使用中性灰或背景色的深色版本。
  • 推荐透明度或视觉强度相当于 #D7D7D7 位于白色背景。
  • 影像主体可以辨认,但边缘应接近消失。
  • 正文使用明显更深的颜色,确保层级分离。

深色背景

  • 字符使用低透明度的冷白或浅灰。
  • 推荐 rgba(255,255,255,0.10–0.22)
  • 根据动态源明暗关系决定是否反转字符梯度。
  • 禁止增加霓虹外发光来提高可见性。

图片或彩色背景

  • 字符颜色使用单一中性色。
  • 先降低背景细节,再判断是否需要字符层。
  • 避免字符与图片纹理同时保持高对比。
  • 动态字符只能有一个视觉色,不随每个字符随机变色。

内容区域

  • 标题、按钮和正文背后的字符可以通过渐变遮罩减弱。
  • 不需要在文字后添加厚重实色卡片来遮挡字符。
  • 字符图层不得影响正文的 WCAG 对比度判断。

12. 可配置令牌

.ascii-system {
  --ascii-font: "Geist Mono", ui-monospace, monospace;
  --ascii-font-size: 18px;
  --ascii-line-height: 18px;
  --ascii-letter-spacing: 6px;
  --ascii-color: #d7d7d7;

  --ascii-fps: 24;
  --ascii-pointer-radius: 6;
  --ascii-activation-chance: 0.05;
  --ascii-life-min: 400ms;
  --ascii-life-max: 700ms;
  --ascii-source-transition: 750ms;
}

JavaScript 配置:

const asciiConfig = {
  ramp: "#N$976543210?!abcxyz;:+=-_,. ",
  fps: 24,
  pointerRadius: 6,
  activationChance: 0.05,
  lifeMin: 400,
  lifeMax: 700,
  sourceTransition: 750
};

13. 性能规范

  • 使用一个 <pre>,不要创建数千个字符节点。
  • 动态源通过低分辨率离屏 Canvas 采样。
  • 以字符网格分辨率读取 ImageData,不要读取完整视频分辨率。
  • 将相同 RGB 值的亮度结果缓存起来。
  • 活跃字符使用 Map<cellIndex, state>,只跟踪受扰动单元。
  • 每帧一次性生成字符串并设置 textContent
  • 页面隐藏时取消 requestAnimationFrame
  • resize 时清空活跃字符、重新测量字体并重建 Canvas。
  • 视频源预加载,但同一时间只播放当前源和过渡中的前一个源。
  • 避免在字符层使用 CSS blur、drop-shadow 和混合模式动画。

14. 减少动态效果

当系统启用:

@media (prefers-reduced-motion: reduce)

停止:

  • 视频采样循环。
  • 字符逐帧替换。
  • 鼠标扰动。
  • 章节动态源切换。

可以选择:

  • 完全移除字符层。
  • 保留单帧静态字符画。
  • 保留一个几乎不可见的单字符占位,避免布局或 hydration 差异。

参考页面在减少动态效果模式下不渲染 <pre> 字符矩阵,只保留一个静态点号占位。

15. 可访问性

  • 字符矩阵必须设置 aria-hidden="true"
  • 字符内容不得被复制、选中或进入搜索结果。
  • 动态字符不承载信息;实际信息必须由正文提供。
  • 图层设置 pointer-events: none
  • 不产生高频全屏闪烁。
  • 字符替换保持低对比,不使用纯黑与纯白快速交替。
  • 触控交互不阻止滚动和系统手势。

16. 禁用模式

  • 不使用随机字符填满屏幕冒充动态影像。
  • 不做绿色终端代码雨或从上向下掉落的字符。
  • 不为每个字符创建 DOM 节点。
  • 不让字符围绕鼠标形成清晰实心圆。
  • 不让鼠标位置产生持续发光、吸附或强烈放大。
  • 不使用 emoji、彩色字符和宽度不一致的符号。
  • 不以 60fps 无条件更新整个矩阵。
  • 不直接读取高分辨率视频的全部像素。
  • 不让字符覆盖正文或降低交互控件可读性。
  • 不在移动端强制模拟 hover 轨迹。
  • 不忽略 prefers-reduced-motion

17. 验收标准

  • 静止观察时,能看出字符矩阵来自持续变化的抽象影像,而不是纯随机文本。
  • 字符从密到疏正确表达动态源的亮度。
  • 鼠标经过时,半径约 6 格内只有少量字符被扰动。
  • 单个扰动字符在约 400–700ms 内逐渐变轻并恢复。
  • 快速移动产生稀疏余波,不出现实体光圈。
  • 鼠标停止后局部扰动在一秒内自然消失。
  • 字符层约以 24fps 更新,具有离散数字质感。
  • 滚动到不同章节时,动态源可在约 750ms 内平滑交叉过渡。
  • 字符层不拦截点击,不可选择,并对辅助技术隐藏。
  • 页面隐藏时暂停,窗口变化后能正确重建网格。
  • 减少动态效果模式下不运行持续动画。