motion · text-decode · 灵感来源:Vercel / Linear hero 的标题解码动效
Text Decode / Scramble · 文字解码 · 动效 / Motion
做一个 Text Decode / Scramble(文字解码):
- 字符逐个替换成随机字符再落回
- 每字符速率固定,速率与总时长分开定义
- 对可访问性名做处理:屏幕阅读器读最终文本
用这些 CSS 变量:--color-surface --color-text --color-text-muted --color-text-subtle --glass-border --shadow-md --shadow-sm
灵感参考:Vercel / Linear hero 的标题解码动效--color-surface --color-text --color-text-muted --color-text-subtle --glass-border --shadow-md --shadow-sm
(无必缺状态)
(尚未接进整页场景)
/r/motion--text-decode.json
免外壳的 markdown 版:/llms/components/motion--text-decode.md定稿前缀 + 乱码后缀prefers-reduced-motion 直接跳过,静态呈现key 重挂载(组件保持无状态对外)A-Z0-9@#$%&*+=/;中文场景传 CJK 字符池避免字形违和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>;
}
src/components/motion/text-decode/