Design System: 动态字符数码与鼠标扰动
一套将动态影像实时转换为字符矩阵,并让字符在鼠标经过时产生稀疏数码余波的交互语言。字符层是持续变化的环境介质,不是正文、代码雨或覆盖内容的特效。
参考交互:Essential。本文提取的是可复用的视觉机制、采样算法和鼠标响应规则,不绑定原网站的页面背景、影像内容或品牌配色。
1. 核心体验
动态字符层由两个系统叠加组成:
- 环境字符动画: 隐藏的动态影像被降低到字符网格分辨率,每个采样格根据亮度映射成不同密度的符号。
- 鼠标局部扰动: 鼠标经过时,附近少量字符暂时脱离影像映射,快速经历“密集字符 → 稀疏字符 → 空格”的离散变化,然后恢复为当前影像对应的字符。
最终效果应表现为:
- 远看像低对比度、持续呼吸的抽象数字影像。
- 近看能辨认出数字、字母和符号组成的字符矩阵。
- 鼠标移动会留下短暂、稀疏的数码扰动。
- 鼠标停止后不会持续形成圆形光斑或吸附效果。
- 字符层永远服从内容层级,不与标题、按钮和媒体争夺注意力。
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内平滑交叉过渡。 - 字符层不拦截点击,不可选择,并对辅助技术隐藏。
- 页面隐藏时暂停,窗口变化后能正确重建网格。
- 减少动态效果模式下不运行持续动画。