文字逐字浮现
Staggered Text让一句话从模糊里一个字一个字浮出来
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
文字参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4 + Motion v14(motion 包)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现文字逐字浮现:把文字拆成单元(中文单字、英文单词),每个单元从下方 22px、6px 模糊中浮现到位——单元时长 520ms、相邻间隔 45ms,缓动 cubic-bezier(0.22, 1, 0.36, 1)。
文件契约
- 只新建一个文件:components/effects/StaggeredText.tsx。
- 依赖:项目已安装 motion@14,从 motion/react 引入。不要新增任何其他包。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client"。
- 拆分规则:CJK 按单字、拉丁文按单词、空格转成 \u00A0、换行渲染成 block 行;导出 StaggeredText,props 为 text、stagger、duration、rise、blur、className。
- 用 motion.span 的 initial / animate / transition,ease 用 [0.22, 1, 0.36, 1],delay = index * stagger / 1000。
- reduced-motion 用 CSS 媒体查询把单元强制到最终状态(.staggered-unit + !important),不要用 JS 分支切换 DOM——否则 SSR 首帧会和客户端不一致;容器加 aria-label(完整文本),动画 span 加 aria-hidden。
规格 —— 精确数值,不许四舍五入或“优化”
- 拆分规则:每个中文字符是一个单元;连续拉丁文保持为单词;空格转成 \u00A0(保证 inline-block 单元里保留宽度);每个 \n 渲染成一个 block 行。
- 每个单元:初始 opacity 0、translateY(22px)、filter: blur(6px);动画到 opacity 1、translateY(0)、blur(0)。
- 时间:单元时长 520ms,延迟 = 单元序号 × 45ms,缓动 cubic-bezier(0.22, 1, 0.36, 1)(不要用 linear)。
- 字体:40px、行高 1.15、字重 600、字距 -0.03em、颜色 #EDEEF0、最大宽度 640px。
- 布局:单元是 inline-block 且 white-space: pre;整段只布局一次,动画结束后不允许有任何位移。
- 无障碍:容器带 aria-label(完整文本),每个动画单元 aria-hidden。
- 重播:组件在 mount 时播放动画;父级用新的 key 重新挂载来重播。不要留下无法清理的定时器。
行为
- mount 时播放一次(仅客户端);不要绑定滚动触发,不要 IntersectionObserver。
- 动画必须从第一帧开始:单元以初始状态声明,不能先闪出最终态。
- 用新的 key 重新挂载组件时,必须按同样的时间参数重新播放。
- 屏幕阅读器只读一次完整文本,而不是逐个单元念;文字要保持可选中。
- prefers-reduced-motion: reduce:跳过动画,直接渲染最终文字。
- 零布局位移:段落从第一帧就保持最终的行数与高度,单元动画结束后不能换行。
- 卸载时清理动画;不要在卸载后更新状态或留下定时器。
禁止
- 不要动画 width / height / margin / padding / letter-spacing;只允许 transform / opacity / filter。
- 不要从拉丁单词中间切开,也不要把空格丢掉。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 除 Motion 外不要引入任何动画库,更不要用 GSAP。
验收 —— 回答前逐项自查
1. 中文按单字、英文按单词拆分;空格与换行都保留。
2. 单元按顺序入场,相邻单元间隔正好是 stagger,单元时长符合配置。
3. 第一帧就是初始状态(透明度 0、偏移、模糊),不会先闪出最终态。
4. 零布局位移:动画前后行数与最终高度完全一致。
5. 屏幕阅读器只读一次完整文本(aria-label),span 为 aria-hidden,文字可选中。
6. prefers-reduced-motion 下不播动画,直接显示最终文字。
7. 换 key 重新挂载后能干净重播;没有定时器/监听泄漏;渲染数值与规格一致。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import { motion } from "motion/react";
import type { CSSProperties } from "react";
import { STAGGERED } from "@/content/effects/staggered-text/constants";
const CJK =
/[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/;
const REDUCED_MOTION_CSS = `@media (prefers-reduced-motion: reduce) {
.staggered-unit { opacity: 1 !important; filter: none !important; transform: none !important; }
}`;
export type StaggeredTextProps = {
text: string;
/** delay between units, in ms */
stagger?: number;
/** per-unit animation duration, in ms */
duration?: number;
/** initial vertical offset, in px */
rise?: number;
/** initial blur, in px */
blur?: number;
className?: string;
style?: CSSProperties;
};
function splitUnits(line: string): string[] {
const units: string[] = [];
let buffer = "";
for (const char of line) {
if (char === " ") {
if (buffer) {
units.push(buffer);
buffer = "";
}
units.push("\u00A0");
} else if (CJK.test(char)) {
if (buffer) {
units.push(buffer);
buffer = "";
}
units.push(char);
} else {
buffer += char;
}
}
if (buffer) units.push(buffer);
return units;
}
export function StaggeredText({
text,
stagger = STAGGERED.defaults.stagger,
duration = STAGGERED.defaults.duration,
rise = STAGGERED.defaults.rise,
blur = STAGGERED.defaults.blur,
className,
style,
}: StaggeredTextProps) {
const lines = text.split("\n").map(splitUnits);
const label = text.replace(/\n/g, " ");
let cursor = 0;
return (
<p
data-demo="staggered-text"
className={className}
style={style}
aria-label={label}
>
<style>{REDUCED_MOTION_CSS}</style>
{lines.map((line, lineIndex) => {
const start = cursor;
cursor += line.length;
return (
<span key={lineIndex} className="block" aria-hidden>
{line.map((unit, unitIndex) => (
<motion.span
key={unitIndex}
className="staggered-unit inline-block whitespace-pre will-change-transform"
initial={{ opacity: 0, y: rise, filter: `blur(${blur}px)` }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
duration: duration / 1000,
delay: ((start + unitIndex) * stagger) / 1000,
ease: STAGGERED.easingPoints,
}}
>
{unit}
</motion.span>
))}
</span>
);
})}
</p>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
把标题按「中文单字 / 英文单词」拆分,每个单元从下方 22px、6px 模糊里浮现,按 45ms 的间隔依次入场。整段文字只占一次布局,动画结束后不留位移。
- 逐字间隔
- 45 ms
- 单字时长
- 520 ms
- 上浮距离
- 22 px
- 初始模糊
- 6 px
- 拆分规则
- 中文单字 / 英文单词
- 缓动
- cubic-bezier(0.22, 1, 0.36, 1)
- 节奏
- 45ms 间隔 · 520ms 单元
- 字号
- 40px · 600
- 行高
- 1.15
- 无障碍
- aria-label 全文 + span aria-hidden
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 中文按单字、英文按单词拆分;空格与换行都保留。
- 单元按顺序入场,相邻单元间隔正好是 stagger,单元时长符合配置。
- 第一帧就是初始状态(透明度 0、偏移、模糊),不会先闪出最终态。
- 零布局位移:动画前后行数与最终高度完全一致。
- 屏幕阅读器只读一次完整文本(aria-label),span 为 aria-hidden,文字可选中。
- prefers-reduced-motion 下不播动画,直接显示最终文字。
- 换 key 重新挂载后能干净重播;没有定时器/监听泄漏;渲染数值与规格一致。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。