Design System: 字符解码显示
一种将随机字符逐步锁定为目标文本的显示逻辑。本文只定义槽位、洗牌、锁定顺序、时间控制、循环和可访问性,不绑定页面背景、字体、字号或具体颜色。
1. 功能目标
目标文本不会直接出现,而是经历一个可读的“解码”过程:
创建固定字符槽位
→ 每个槽位显示随机字符
→ 未解码槽位持续洗牌
→ 槽位按指定顺序锁定
→ 完整目标文本保持显示
→ 可选:自动重新播放
核心原则:
- 每个目标字符对应一个固定槽位。
- 未锁定槽位持续显示随机字符。
- 锁定槽位立即停止变化,并显示最终字符。
- 解码进度由总时长控制,不依赖随机概率。
- 文本宽度在动画期间保持稳定。
- 动效不能影响辅助技术读取真实文本。
2. 基础配置
const config = {
target: "TAKE IT APART",
deck:
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"0123456789" +
"!@#$%&*+=",
mode: "left",
duration: 1600,
fps: 18,
holdDuration: 1400,
loop: true
};
参数含义
| 参数 | 含义 |
|---|---|
target |
最终显示文本 |
deck |
未解码状态可使用的字符集合 |
mode |
槽位锁定顺序 |
duration |
完成一次解码所需时间 |
fps |
随机字符刷新频率 |
holdDuration |
完整文本显示后的停留时间 |
loop |
是否自动重新播放 |
3. 字符槽位
初始化时,为目标文本中的每个字符创建一个独立槽位:
function createSlots(container, characters) {
const fragment = document.createDocumentFragment();
for (const character of characters) {
const slot = document.createElement("span");
slot.dataset.target = character;
slot.textContent = pickRandomCharacter();
fragment.appendChild(slot);
}
container.replaceChildren(fragment);
}
每个槽位包含:
type GlyphSlot = {
index: number;
targetCharacter: string;
locked: boolean;
};
固定槽位宽度
每个槽位必须保留稳定宽度,避免字符变化导致整行抖动:
.glyph-slot {
display: inline-block;
min-width: 0.58em;
text-align: center;
}
固定槽位是功能要求,不是固定视觉风格。具体宽度可根据字体调整。
4. 字符集合
随机字符从 deck 中等概率选取:
function pickRandomCharacter() {
const index =
Math.floor(Math.random() * deck.length);
return deck[index];
}
字符集合规则
- 字符宽度应尽可能一致。
- 字符集合应与目标文本语言相容。
- 不加入换行符或不可见控制字符。
- 不加入 emoji 或宽度变化明显的图形字符。
- 字符数量无需很大,约
20–60个足够形成变化。 - 字符集合只影响未解码状态,不影响最终文本。
大小写
如果目标文本区分大小写:
deck应同时包含大写和小写字符。- 最终锁定时必须使用目标文本原字符。
5. 文本拆分
英文和数字可以使用:
const characters = [...target];
包含 emoji、组合音标或复杂语言时,使用字素分割:
const segmenter = new Intl.Segmenter(
undefined,
{ granularity: "grapheme" }
);
const characters = [
...segmenter.segment(target)
].map(item => item.segment);
不要直接使用 target.length 处理所有语言,因为一个用户可见字符可能由多个 UTF-16 单元组成。
6. 锁定顺序
为每个槽位生成索引:
let order = Array.from(
{ length: characters.length },
(_, index) => index
);
从左到右
mode = "left";
0 → 1 → 2 → 3 → ...
适合:
- 标题。
- 单行短语。
- 需要保持正常阅读顺序的文本。
从右到左
mode = "right";
order.reverse();
... → 3 → 2 → 1 → 0
这只是动画顺序,不代表文本排版方向。真正的 RTL 语言仍需遵循页面的 dir 属性。
随机锁定
mode = "random";
使用 Fisher–Yates 洗牌:
for (let i = order.length - 1; i > 0; i--) {
const j =
Math.floor(Math.random() * (i + 1));
[order[i], order[j]] =
[order[j], order[i]];
}
随机顺序只在一次播放开始时生成一次。不要在每一帧重新洗牌锁定顺序。
7. 空格处理
原始逻辑把空格视为普通槽位:
- 初始化时空格位置也显示随机字符。
- 当该槽位被锁定后,才变成真正空格。
这样可以让整个短语在解码前保持不可读。
可配置两种模式:
spaceMode: "scramble" | "preserve";
Scramble
- 空格参与随机字符变化。
- 空格按照顺序被锁定。
- 效果更完整、更难提前识别目标文本。
Preserve
- 空格从第一帧开始保持为空。
- 空格不计入解码进度。
- 单词边界始终稳定,更容易阅读。
如果空格不参与锁定,需要单独生成可解码索引:
order = characters
.map((character, index) => ({
character,
index
}))
.filter(item => item.character !== " ")
.map(item => item.index);
8. 播放初始化
function play() {
createSlots(container, characters);
if (reduceMotion) {
revealImmediately();
return;
}
startedAt = performance.now();
lastSwapAt = 0;
order = createLockOrder(
characters.length,
mode
);
state = "decoding";
}
一次播放必须重置:
- 槽位 DOM。
- 所有随机字符。
- 开始时间。
- 上一次刷新时间。
- 锁定顺序。
- 当前状态。
不要沿用上一轮的锁定集合。
9. 时间进度
const elapsed = now - startedAt;
const ratio = Math.min(
1,
elapsed / duration
);
ratio 始终限制在 0–1。
已锁定数量:
const lockedCount = Math.floor(
ratio * order.length
);
已锁定索引:
const locked = new Set(
order.slice(0, lockedCount)
);
进度特性
- 解码速度是线性的。
- 总时长固定。
- 字符数量越多,每次刷新可能同时锁定多个槽位。
- 字符数量越少,某些帧可能没有新增锁定槽位。
- 最后一帧必须确保全部槽位锁定。
10. 帧率节流
动画循环使用 requestAnimationFrame,字符替换按照目标 fps 节流:
function tick(now) {
const frameInterval = 1000 / fps;
if (now - lastSwapAt >= frameInterval) {
lastSwapAt = now;
renderFrame(now);
}
animationFrameId =
requestAnimationFrame(tick);
}
参考配置:
fps = 18
frameInterval ≈ 55.56ms
使用低于屏幕刷新率的字符更新频率,可以形成离散的机械解码感。
不要使用 setInterval 作为主动画循环,因为:
- 页面阻塞时容易累积漂移。
- 浏览器标签隐藏后仍可能浪费资源。
- 与屏幕绘制节奏不同步。
11. 单帧渲染
function renderFrame(now) {
const ratio = getProgress(now);
const locked = getLockedIndices(ratio);
for (
let index = 0;
index < characters.length;
index++
) {
const slot = container.children[index];
if (locked.has(index)) {
slot.textContent = characters[index];
slot.dataset.state = "resolved";
} else {
slot.textContent =
pickRandomCharacter();
slot.dataset.state = "scrambling";
}
}
if (ratio >= 1) {
state = "holding";
}
}
Resolved
- 显示目标字符。
- 不再参与随机刷新。
- 状态保持到下一次
play()。
Scrambling
- 每个允许刷新的帧重新随机。
- 不保留上一帧随机字符。
- 不应意外显示并锁定错误字符。
12. 状态机
推荐使用四种状态:
type DecodeState =
| "idle"
| "decoding"
| "holding"
| "stopped";
idle
→ play()
→ decoding
→ progress = 1
→ holding
→ holdDuration 结束
→ decoding(循环)
如果 loop = false:
holding
→ stopped
13. 完成与停留
当 ratio >= 1:
- 所有槽位显示目标字符。
- 所有槽位进入
resolved状态。 - 记录完成时间。
- 保持目标文本
holdDuration。
if (
ratio >= 1 &&
now - startedAt >=
duration + holdDuration
) {
if (loop) {
play();
} else {
stop();
}
}
参考:
解码时长:1600ms
完整文本停留:1400ms
循环总周期:约3000ms
完成后必须停留足够时间,不能刚变得可读就立刻重新打乱。
14. 循环策略
支持三种播放方式:
自动循环
loop = true;
- 解码完成。
- 停留。
- 重新生成随机字符。
- 开始下一轮。
适合短标题或单一展示文字。
单次播放
loop = false;
- 解码一次。
- 最终文本永久保持。
适合页面首屏标题和内容出现动画。
交互重播
replayOn:
| "click"
| "hover"
| "focus"
| "manual";
推荐点击或明确按钮重播。不要在鼠标轻微经过时频繁清空可读文字。
15. 控制接口
type DecodeController = {
play(): void;
replay(): void;
pause(): void;
resume(): void;
stop(options?: {
revealTarget?: boolean;
}): void;
destroy(): void;
};
Pause
- 保留当前槽位内容。
- 记录暂停时间。
- 停止请求下一帧。
Resume
- 修正
startedAt,排除暂停时间。 - 从当前进度继续。
Stop
可配置:
- 保持当前帧。
- 立即显示目标文本。
- 清空组件。
Destroy
- 取消
requestAnimationFrame。 - 移除事件监听器。
- 清理观察器。
- 释放 DOM 引用。
16. 页面可见性
页面隐藏时暂停:
document.addEventListener(
"visibilitychange",
() => {
if (document.hidden) {
pause();
} else {
resume();
}
}
);
不要让后台标签中的动画继续循环。
恢复时不能把隐藏期间的时间直接计入进度,否则用户返回时可能看见文本突然完成或立即重播。
17. 视口触发
如果组件不在首屏,使用 IntersectionObserver:
const observer = new IntersectionObserver(
entries => {
const entry = entries[0];
if (entry.isIntersecting) {
play();
} else {
pause();
}
},
{ threshold: 0.4 }
);
推荐:
- 至少
40%可见时启动。 - 离开视口后暂停。
- 单次播放组件记录
hasPlayed,避免滚动时反复解码。
18. 减少动态效果
const reduceMotion = matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
启用减少动态效果时:
function revealImmediately() {
for (
let index = 0;
index < characters.length;
index++
) {
const slot = container.children[index];
slot.textContent = characters[index];
slot.dataset.state = "resolved";
}
state = "stopped";
}
- 不运行随机字符刷新。
- 不启动
requestAnimationFrame。 - 直接展示可读文本。
- 不自动循环。
必须监听系统设置变化:
motionQuery.addEventListener(
"change",
handleMotionPreference
);
如果用户运行中切换到减少动态效果,立即停止并显示最终文本。
19. 可访问性
持续变化的字符不应被屏幕阅读器逐帧朗读。
推荐结构:
<span
class="decode-visual"
aria-hidden="true"
></span>
<span class="sr-only">
TAKE IT APART
</span>
或者:
<span
class="decode"
aria-label="TAKE IT APART"
>
<span
class="decode-visual"
aria-hidden="true"
></span>
</span>
规则:
- 随机字符层设置
aria-hidden="true"。 - 提供不会变化的真实文本。
- 不使用
aria-live播报每次字符变化。 - 如果文本是标题,语义标题必须包含真实文本。
- 动效不是传达内容的唯一方式。
20. 多实例
页面中存在多个解码组件时:
- 每个实例拥有独立的计时器和状态。
- 不共享
startedAt。 - 可以共享字符集合配置。
- 同屏自动循环实例不宜过多。
- 推荐最多
1–3个同时活动实例。
如果多个词需要依次播放,使用统一时间轴:
标题 A 解码
→ 停留
→ 标题 B 解码
→ 停留
不要让所有标题同时高速洗牌。
21. 性能规范
- 创建槽位时使用
DocumentFragment。 - 一次播放只创建一次槽位。
- 每帧只更新
textContent和状态属性。 - 锁定后不再更新对应槽位。
- 使用
requestAnimationFrame+ fps 节流。 - 页面隐藏或组件不可见时暂停。
- 销毁组件时取消 animation frame。
- 短文本优先;建议不超过约
40–60个槽位。 - 长段正文不使用逐字符解码。
可以进一步优化锁定查找:
const rankByIndex = new Map();
order.forEach((index, rank) => {
rankByIndex.set(index, rank);
});
单帧判断:
const locked =
rankByIndex.get(index) < lockedCount;
这样无需每帧创建新的 Set。
22. 完整伪代码
function createDecoder(container, config) {
const characters =
splitIntoGraphemes(config.target);
let order = [];
let rankByIndex = new Map();
let startedAt = 0;
let lastSwapAt = 0;
let completedAt = 0;
let animationFrameId = 0;
let state = "idle";
function play() {
cancelAnimationFrame(animationFrameId);
createSlots(container, characters);
if (reduceMotion.matches) {
revealImmediately();
return;
}
order = createLockOrder(
characters.length,
config.mode
);
rankByIndex = new Map(
order.map((index, rank) => [
index,
rank
])
);
startedAt = performance.now();
lastSwapAt = 0;
completedAt = 0;
state = "decoding";
animationFrameId =
requestAnimationFrame(tick);
}
function tick(now) {
if (
now - lastSwapAt >=
1000 / config.fps
) {
lastSwapAt = now;
if (state === "decoding") {
const ratio = Math.min(
1,
(now - startedAt) /
config.duration
);
const lockedCount =
Math.floor(
ratio * order.length
);
renderSlots(lockedCount);
if (ratio >= 1) {
state = "holding";
completedAt = now;
}
} else if (
state === "holding" &&
now - completedAt >=
config.holdDuration
) {
if (config.loop) {
play();
return;
}
state = "stopped";
}
}
if (
state !== "stopped"
) {
animationFrameId =
requestAnimationFrame(tick);
}
}
function renderSlots(lockedCount) {
for (
let index = 0;
index < characters.length;
index++
) {
const slot =
container.children[index];
const locked =
rankByIndex.get(index) <
lockedCount;
if (locked) {
slot.textContent =
characters[index];
slot.dataset.state =
"resolved";
} else {
slot.textContent =
pickRandomCharacter();
slot.dataset.state =
"scrambling";
}
}
}
return {
play,
replay: play,
stop,
destroy
};
}
23. 禁用逻辑
- 不让已经锁定的字符继续变化。
- 不在每一帧重新生成锁定顺序。
- 不使用概率决定最终是否锁定,完成时间必须可预测。
- 不让字符变化导致整行宽度反复跳动。
- 不使用无限高速
requestAnimationFrame更新字符。 - 不在页面隐藏后继续运行。
- 不让完整文本刚出现就立即重新打乱。
- 不用
aria-live逐帧播报随机字符。 - 不对长段正文应用解码效果。
- 不忽略空格、标点和复杂字素的拆分问题。
- 不在减少动态效果模式下继续洗牌。
24. 验收标准
- 首帧为与目标长度一致的随机字符槽位。
- 所有槽位宽度稳定,字符变化时整行不跳动。
- 未锁定字符按目标 fps 持续变化。
- 已锁定字符不再变化。
- 左、右和随机三种锁定顺序均正常工作。
- 解码进度由
duration准确控制。 - 动画完成时所有字符与目标文本完全一致。
- 完整文本至少保持
holdDuration后才允许循环。 - 单次模式完成后永久保持目标文本。
- 页面隐藏时暂停,恢复后从原进度继续。
- 复杂 Unicode 文本不会被错误拆分。
- 屏幕阅读器只读取静态目标文本。
- 减少动态效果模式下立即显示最终文本且不循环。