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 文本不会被错误拆分。
  • 屏幕阅读器只读取静态目标文本。
  • 减少动态效果模式下立即显示最终文本且不循环。