列表错落入场
Staggered List一组内容,一件接一件落到位置上
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
布局RECENT ACTIVITY
最近活动
30ms · 320ms · 12px · cap 8
- 构建通过09:12
- 提示词已导出09:08
- 设计令牌已同步08:54
- 视觉验收通过08:31
- 组件已合并08:02
- 预览环境已部署07:45
重播 = 用新的 key 重新挂载列表
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4,入场用纯 CSS keyframes,不引入动画库。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现列表错落入场:列表首次挂载时,每一行从下方 12px 处用 320ms、cubic-bezier(0.2, 0.8, 0.2, 1) 缓动做一次淡入上升;相邻行相差 30ms,延迟在第 8 项之后封顶。每次挂载只播一次——父组件重渲染不能重播,重播必须重新挂载列表。
文件契约
- 只新建一个文件:components/effects/StaggerList.tsx。
- 依赖:无需额外依赖:keyframes 由组件内的 <style> 注入,逐行动画只依赖 CSS 变量。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";在组件里用 <style> 注入 stagger-list-in keyframes,并在 prefers-reduced-motion: reduce 里用 !important 关掉动画与位移。
- 导出 StaggerList(props:items、stagger、duration、rise、cap、className、style、label)与纯函数 staggerStyle(index, step = 30, max = 8),后者返回 { animationDelay: Math.min(index, max) * step + "ms" }。
- 把 staggerStyle(index, stagger, cap) 的结果放到每行的行内 style;时长与上浮距离通过 CSS 变量 --stagger-duration / --stagger-rise 从 ul 统一下发,改 props 不用改 keyframes。
- 每行 animation: ... both,首帧即初始态;只动 transform 与 opacity,绝不逐帧改 width / height / top / left。
- 用一个 useRef 记录是否已经播完:最后一行 animationend 后置位并移除动画 class,父组件重渲染不会重播;reduced-motion 时挂载即置位。
规格 —— 精确数值,不许四舍五入或“优化”
- 每行动画:opacity 0 → 1,transform translateY(12px) → translateY(0),时长 320ms,缓动 cubic-bezier(0.2, 0.8, 0.2, 1),animation-fill-mode: both(第一帧就已经是初始态)。
- 每行延迟:30ms × min(index, 8)。index 从 0 开始,所以第一行延迟 0ms,从第 8 项起都以 240ms 封顶。
- 只允许动画 transform 与 opacity;行不允许动画 width、height、margin、top、left。
- 列表几何:纵向列表,行间距 8px;每行是带边框的卡片,圆角 10px,上下内边距 8px、左右 12px,左侧 6px 的 lime 圆点,标题字号 12.5px,右侧可选 10px 等宽小标签。
- 重播等于重新挂载:一次挂载只播一次,父组件重渲染不重播,只有换 key / 重建 DOM 才重播。
- 行必须是真实 <li>,外层是 <ul>,列表要有可访问名称。
行为
- 入场是一次性的:未播放时行带动画 class;最后一行 animationend 触发后标记已播放并移除动画 class。父组件重渲染不允许重播入场。
- 延迟用 min(index, cap) × stagger 手算出来,写进行的行内 animation-delay;绝不能用链式 setTimeout 逐行启动。
- 重播由组件外部控制(重新挂载 / 新 key);不要把重播方法做进组件本身。
- prefers-reduced-motion: reduce:不播 keyframes、不位移——首帧就是 opacity: 1 / translateY(0),行内 animation-delay 也一并去掉。
- 行本身不可交互,保持语义化,不要把整行做成按钮。如果演示需要「重播」按钮,必须是真实 <button type="button">,可 Tab 聚焦,Enter / Space 可触发,focus-visible 显示 2px #C9F45A、外扩 3px 的描边。
- 卸载时清理;不要在 document/window 上绑监听,不要每帧触发状态更新;行内 <style> 是唯一副作用,必须留在组件内部作用域。
禁止
- 不要用动画改变 width / height / top / left / margin / box-shadow。
- 不要用链式 setTimeout 启动行,也不要从 JavaScript 逐帧改 opacity / translateY。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider。
- 不要改动全局 CSS、Tailwind 配置、package.json 或上面文件之外的任何文件。
- 不要引入任何动画库;入场必须是用行内变量驱动的纯 CSS keyframes。
验收 —— 回答前逐项自查
1. 每行的时长、上浮距离、行间隔与延迟封顶值都和当前参数完全一致(默认 320ms / 12px / 30ms / 8),延迟严格等于 min(index, cap) × stagger。
2. 在入场后 60–80ms 采样,已经开始的行都严格处于起点与终点之间(opacity 0–1、translateY 0–rise),没有跳帧或先闪出最终态。
3. 最后一行 animationend 后动画 class 被移除、transform 归零,不残留内联 animation-delay 或 style。
4. 整个入场过程中 ul 的 boundingBox 完全不变,只有 transform / opacity 变化,没有布局抖动。
5. prefers-reduced-motion 下不播动画、没有位移,列表在首帧就是最终态。
6. 「重播」按钮可聚焦,Enter / Space 能触发重新挂载;焦点环可见(2px lime,外扩 3px)。
7. 控制台 0 报错 / 警告;父组件重渲染不会重播;卸载后没有残留的 <style> 或定时器。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { STAGGER_LIST } from "@/content/effects/stagger-list/constants";
/**
* One keyframe set for every row. The offset, duration and easing come from
* CSS variables so a single <style> block covers every configured value.
*/
const STYLES = `@keyframes stagger-list-in {
from {
opacity: 0;
transform: translateY(var(--stagger-rise, ${STAGGER_LIST.defaults.rise}px));
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-list-item--animate {
opacity: 0;
animation: stagger-list-in var(--stagger-duration, ${STAGGER_LIST.defaults.duration}ms) ${STAGGER_LIST.easing} both;
}
@media (prefers-reduced-motion: reduce) {
.stagger-list-item--animate {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}`;
/** A single row; `id` must be stable so reordering keeps the same React node. */
export type StaggerListItem = {
id: string;
title: string;
meta?: string;
};
export type StaggerListProps = {
items: StaggerListItem[];
/** Delay between two adjacent rows, in ms. */
stagger?: number;
/** Duration of one row's fade-up, in ms. */
duration?: number;
/** Initial upward offset of a row, in px. */
rise?: number;
/** Index after which the delay stops growing, so long lists stay snappy. */
cap?: number;
className?: string;
style?: CSSProperties;
/** Accessible name of the list. */
label?: string;
};
type CSSVarStyle = CSSProperties & Record<`--${string}`, string>;
/**
* Pure helper: `delay = min(index, max) * step`.
*
* Returns a style object ready for a row's `style` prop, e.g.
* `staggerStyle(3, 30, 8)` -> `{ animationDelay: "90ms" }`.
*/
export function staggerStyle(
index: number,
step: number = STAGGER_LIST.staggerStyle.step,
max: number = STAGGER_LIST.staggerStyle.max,
): CSSProperties {
const safeIndex = Math.max(0, Math.floor(Number.isFinite(index) ? index : 0));
const safeStep = Math.max(0, Number.isFinite(step) ? step : 0);
const safeMax = Math.max(0, Math.floor(Number.isFinite(max) ? max : 0));
return { animationDelay: `${Math.min(safeIndex, safeMax) * safeStep}ms` };
}
export function StaggerList({
items,
stagger = STAGGER_LIST.defaults.stagger,
duration = STAGGER_LIST.defaults.duration,
rise = STAGGER_LIST.defaults.rise,
cap = STAGGER_LIST.defaults.cap,
className,
style,
label,
}: StaggerListProps) {
// The entrance owns no timer: rows animate declaratively and a ref records
// that the one-shot has already played, so later parent renders never replay it.
const [played, setPlayed] = useState(false);
const playedRef = useRef(false);
const lastIndex = items.length - 1;
const markPlayed = () => {
if (playedRef.current) return;
playedRef.current = true;
setPlayed(true);
};
// Reduced motion: drop the inline delay as well; the media query already
// removes the keyframes, so rows are visible on the very first paint.
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setPlayed(true);
playedRef.current = true;
}
}, []);
const vars: CSSVarStyle = {
"--stagger-rise": `${rise}px`,
"--stagger-duration": `${duration}ms`,
};
return (
<ul
aria-label={label}
className={`flex list-none flex-col ${className ?? ""}`}
style={{ ...vars, gap: STAGGER_LIST.list.gap, ...style }}
>
<style>{STYLES}</style>
{items.map((item, index) => (
<li
key={item.id}
data-stagger-index={index}
data-stagger-played={played ? "true" : "false"}
className={`stagger-list-item flex items-center justify-between gap-3 border border-white/[0.08] bg-[#12151a] ${
played ? "" : "stagger-list-item--animate"
}`}
style={{
borderRadius: STAGGER_LIST.list.radius,
paddingInline: STAGGER_LIST.list.paddingX,
paddingBlock: STAGGER_LIST.list.paddingY,
...(played ? {} : staggerStyle(index, stagger, cap)),
}}
onAnimationEnd={() => {
if (index === lastIndex) markPlayed();
}}
>
<span className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#c9f45a]"
/>
<span
className="truncate text-[#edeef0]"
style={{ fontSize: STAGGER_LIST.list.titleSize }}
>
{item.title}
</span>
</span>
{item.meta ? (
<span
className="shrink-0 font-mono text-[#6a6d74]"
style={{ fontSize: STAGGER_LIST.list.metaSize }}
>
{item.meta}
</span>
) : null}
</li>
))}
</ul>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
列表第一次出现时,每一行用 320ms 做一次 fade-up:透明度从 0 到 1,位置从下方 12px 回到 0,缓动 cubic-bezier(0.2, 0.8, 0.2, 1),fill-mode both 保证第一帧就是初始态,不会先闪出最终态。相邻两行相差 30ms,延迟在第 8 项之后封顶,长列表不会越等越久。入场一个挂载周期只播一次,父组件重渲染不会重播;要重播就得重新挂载列表。
- 行间隔
- 30 ms
- 单项时长
- 320 ms
- 上浮距离
- 12 px
- 延迟封顶项数
- 8
- 逐项延迟上限
- 240ms
- 6 行总时长
- 470ms
- 缓动
- cubic-bezier(0.2, 0.8, 0.2, 1)
- 重播方式
- 用新 key 重新挂载列表
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 每行的时长、上浮距离、行间隔与延迟封顶值都和当前参数完全一致(默认 320ms / 12px / 30ms / 8),延迟严格等于 min(index, cap) × stagger。
- 在入场后 60–80ms 采样,已经开始的行都严格处于起点与终点之间(opacity 0–1、translateY 0–rise),没有跳帧或先闪出最终态。
- 最后一行 animationend 后动画 class 被移除、transform 归零,不残留内联 animation-delay 或 style。
- 整个入场过程中 ul 的 boundingBox 完全不变,只有 transform / opacity 变化,没有布局抖动。
- prefers-reduced-motion 下不播动画、没有位移,列表在首帧就是最终态。
- 「重播」按钮可聚焦,Enter / Space 能触发重新挂载;焦点环可见(2px lime,外扩 3px)。
- 控制台 0 报错 / 警告;父组件重渲染不会重播;卸载后没有残留的 `<style>` 或定时器。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。