# 文字解码（Text Decode / Scramble）

- 类型 / 材质：`motion` / `text-decode`（解码）
- 状态：done ｜ 最后提交：2026-10-30
- 灵感来源：Vercel / Linear hero 的标题解码动效
- 依据标准：`/design#motion` ｜ 组件页：`/showcase/component/motion/text-decode` ｜ 条目：`/r/motion--text-decode.json`
- 必缺状态：（无必缺状态）
- 整页场景：尚未接入

## 规格

- 字符逐个替换成随机字符再落回
- 每字符速率固定，速率与总时长分开定义
- 对可访问性名做处理：屏幕阅读器读最终文本

## 参数（CSS 变量）

`--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--glass-border` `--shadow-md` `--shadow-sm`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `text` | `string` | 必填 | — |
| `charset` | `string` | 可选 | 乱码字符池；中文文本建议传 CJK 字符池 |
| `duration` | `number` | 可选 | 解码总时长 ms |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「文字解码」— Text Decode / Scramble（动效 / Motion）
灵感来源：Vercel / Linear hero 的标题解码动效
规格：
- 字符逐个替换成随机字符再落回
- 每字符速率固定，速率与总时长分开定义
- 对可访问性名做处理：屏幕阅读器读最终文本
参数（CSS 变量）：--color-surface --color-text --color-text-muted --color-text-subtle --glass-border --shadow-md --shadow-sm
必缺状态：（无必缺状态）
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/motion/text-decode · 标准 motion /design#motion · 调研笔记 /showcase/component/motion/text-decode#notes
```

## 调研笔记

import { useEffect, useRef, useState } from 'react';

const DEFAULT_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*+=/';

interface Props {
  text: string;
  /** 乱码字符池；中文文本建议传 CJK 字符池 */
  charset?: string;
  /** 解码总时长 ms */
  duration?: number;
  className?: string;
}

export function TextDecode({
  text,
  charset = DEFAULT_CHARSET,
  duration = 900,
  className = '',
}: Props) {
  // SSR 输出终稿：首屏不闪空白，挂载后再打乱解码
  const [display, setDisplay] = useState(text);
  const raf = useRef(0);

  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const chars = charset.split('');
    const rand = () => chars[Math.floor(Math.random() * chars.length)];
    let start: number | null = null;

    const tick = (now: number) => {
      if (start === null) start = now;
      const t = Math.min(1, (now - start) / duration);
      const locked = Math.floor(t * text.length);
      let out = text.slice(0, locked);
      for (let i = locked; i < text.length; i++) {
        out += text[i] === ' ' ? ' ' : rand();
      }
      setDisplay(out);
      if (t < 1) {
        raf.current = requestAnimationFrame(tick);
      } else {
        setDisplay(text);
      }
    };

    raf.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf.current);
  }, [text, charset, duration]);

  return <span className={className}>{display}</span>;
}
