数字滚动
Animated Number数字不是跳过去的,是一路数过去的
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
文字连点刷新:从当前显示值改道重数
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4(不需要动画库)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现数字滚动:目标值变化时,用 420ms 的 requestAnimationFrame 补间从当前显示的数值数到新值,进度由手写的 cubic-bezier(0.2, 0.8, 0.2, 1) 曲线给出。补间中途换目标必须从当前显示值重新开始;NaN / Infinity 等非有限值原样显示;最后一帧必须精确等于目标值。
文件契约
- 只新建一个文件:components/effects/AnimatedNumber.tsx。
- 依赖:无;rAF 与缓动求解都手写,不要引入动画库。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";同一个文件里导出 useAnimatedNumber(target, options) hook 与 <AnimatedNumber value duration precision separators /> 组件。
- hook 用 ref 保存当前显示值、起始值、起始时间与 rAF id;target 变化时把「当前显示值」作为新起点(中途换目标从当前值改道),再开一轮 requestAnimationFrame。
- 进度按自实现 cubic-bezier(0.2, 0.8, 0.2, 1) 求值:先 Newton 迭代(最多 6 次、ε=0.000001)解 x → t,再算 y;每帧 value = from + (to − from) × y,进度到 1 时直接写 to。
- 格式化:非有限值原样 String(value);否则 toFixed(precision) 后按 separators 插千分位,并用 tabular-nums 固定数字宽度。
- 卸载时 cancelAnimationFrame;prefers-reduced-motion 命中时一次写入终值、不启动 rAF;首帧不播动画,只响应之后的 value 变化。
规格 —— 精确数值,不许四舍五入或“优化”
- 补间时长:420ms;最后一帧必须精确落在目标值上,不能停在四舍五入后的中间值。
- 进度曲线:手写 cubic-bezier(0.2, 0.8, 0.2, 1)——控制点 P1 (0.2, 0.8)、P2 (0.2, 1)。用 Newton-Raphson(最多 6 次迭代、ε=0.000001)解 x(t) = progress 得到 t,再取 y(t),y 就是缓动后的进度。
- 每帧数值:value = from + (to − from) × y。from 取补间开始时屏幕上显示的值;如果这个值是非有限值(上一目标刚透传过 NaN / ±Infinity),from 取 0。
- 格式化:toFixed(0)(小数位 0),千分位开启,整数部分每三位插一个 ,;非有限值直接 String(value)("NaN"、"Infinity"、"-Infinity"),不要格式化。
- 中断重启:补间中新目标到来时先取消未执行的帧,再从当前显示值重新开始;完整跑满 420ms,不要扣掉已经过去的时间。
- 显示盒:font-variant-numeric: tabular-nums、右对齐、min-width 9ch;只改文字内容——不要动画布局,也不要加 transform。
行为
- 状态机(文字状态图):
idle —(target 变为有限值且非 reduced-motion)→ tweening { from: 当前显示值, start: now }
tweening —(target 再次变化)→ tweening { from: 当前显示值, start: now }(先取消未执行的帧)
tweening —(elapsed ≥ duration)→ settled { display: target }(写入终值并停止)
idle | tweening —(target 非有限:NaN / ±Infinity)→ passthrough { display: String(target) }
tweening —(卸载)→ cancelled(cancelAnimationFrame,之后不再 setState)
reduced-motion:idle | passthrough —(target 变化)→ settled(≤1ms 内写入终值,绝不启动 rAF)
- 首帧不播动画;只有之后的 value 变化才启动补间。
- 连续改目标必须顺滑:每次新目标都读取补间中正在显示的数值,而不是上一个目标值。
- 宿主布局保持稳定:等宽数字、右对齐、固定宽度下限;不要动画 width / height / top / left。
- aria-label / role / 可见文案属于外层 UI:不要新增、改名或删除。
- 卸载时清理:取消 animation frame;不要留下定时器,不要绑 document/window 监听,卸载后不要更新状态。
禁止
- 不要改变交互语义、可访问名称(aria-label / role / 可见文案)或请求时机;只实现视觉动效。
- 不要逐帧动画 width / height / top / left;数字只更新文本内容。
- 不要用链式 setTimeout / setInterval 拼补间——用 requestAnimationFrame(或目标栈的补间引擎)。
- 不要新增依赖,也不要升级已安装的依赖。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要引入任何动画库——GSAP、Motion 都不行。
验收 —— 回答前逐项自查
1. 补间时长严格等于 duration(默认 420ms):200ms / 1200ms 两端都能正常起止;小数位与千分位显示符合 precision / separators。
2. 从旧值数到新值时,60–80ms 处的显示值严格大于起始值且严格小于结束值,不是瞬间跳变。
3. 结束帧显示值精确等于目标值;没有残留的 rAF,也没有残留的内联 transform / 动画样式。
4. 数字使用 tabular-nums 且容器宽度固定:滚动全程 boundingBox 稳定,不抖动、不换行。
5. prefers-reduced-motion 时目标变化在 ≤1ms 内直接落到最终值,不启动 rAF。
6. 键盘可完成 Demo 里的全部操作(Tab 聚焦、Enter / Space 触发),焦点环清晰可见。
7. 0 console 报错;中途切换目标不抛错;卸载后不再更新状态、没有残留的 rAF 回调。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { ANIMATED_NUMBER } from "@/content/effects/animated-number/constants";
const C = ANIMATED_NUMBER;
/* ------------------------------------------------------------------ */
/* cubic-bezier(0.2, 0.8, 0.2, 1), solved with Newton-Raphson */
/* ------------------------------------------------------------------ */
/** one coordinate of a cubic bezier curve at parameter t */
function bezierValue(t: number, p1: number, p2: number): number {
const inverse = 1 - t;
return (
3 * inverse * inverse * t * p1 + 3 * inverse * t * t * p2 + t * t * t
);
}
/** d/dt of the same coordinate, used by Newton-Raphson */
function bezierSlope(t: number, p1: number, p2: number): number {
const inverse = 1 - t;
return (
3 * inverse * inverse * p1 +
6 * inverse * t * (p2 - p1) +
3 * t * t * (1 - p2)
);
}
/**
* Eased progress for `cubic-bezier(0.2, 0.8, 0.2, 1)`: solve x(t) = progress
* for t with Newton-Raphson, then return y(t).
*/
function cubicBezierProgress(progress: number): number {
const x = Math.min(1, Math.max(0, progress));
if (x <= 0 || x >= 1) return x;
let t = x;
for (let i = 0; i < C.solver.maxIterations; i += 1) {
const error = bezierValue(t, C.curve.x1, C.curve.x2) - x;
if (Math.abs(error) < C.solver.epsilon) break;
const slope = bezierSlope(t, C.curve.x1, C.curve.x2);
if (slope <= 0) break;
const next = t - error / slope;
// Newton can shoot outside [0, 1]; restart from the linear guess instead
t = next > 0 && next < 1 ? next : x;
}
return bezierValue(t, C.curve.y1, C.curve.y2);
}
/* ------------------------------------------------------------------ */
/* formatting */
/* ------------------------------------------------------------------ */
export function formatAnimatedNumber(
value: number,
precision: number,
separators: boolean,
): string {
if (!Number.isFinite(value)) return String(value);
const safePrecision = Math.min(2, Math.max(0, Math.round(precision)));
const fixed = value.toFixed(safePrecision);
if (!separators) return fixed;
const sign = fixed.startsWith("-") ? "-" : "";
const body = sign ? fixed.slice(1) : fixed;
const [integer, fraction] = body.split(".");
const grouped = integer.replace(
/\B(?=(\d{3})+(?!\d))/g,
C.formatting.groupSeparator,
);
return `${sign}${grouped}${
fraction ? `${C.formatting.decimalSeparator}${fraction}` : ""
}`;
}
function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
if (typeof window.matchMedia !== "function") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/* ------------------------------------------------------------------ */
/* hook */
/* ------------------------------------------------------------------ */
export type AnimatedNumberOptions = {
/** tween length in ms */
duration?: number;
/** decimals shown after the decimal point */
precision?: number;
/** group thousands with "," */
separators?: boolean;
};
export type AnimatedNumberState = {
/** raw number currently on screen (may be mid-tween) */
value: number;
/** formatted text for that number */
display: string;
};
type Tween = {
from: number;
to: number;
start: number;
duration: number;
};
export function useAnimatedNumber(
target: number,
options: AnimatedNumberOptions = {},
): AnimatedNumberState {
const rawDuration = options.duration ?? C.defaults.duration;
const duration =
Number.isFinite(rawDuration) && rawDuration >= 0
? rawDuration
: C.defaults.duration;
const precision = options.precision ?? C.defaults.precision;
const separators = options.separators ?? C.defaults.separators;
const [displayed, setDisplayed] = useState<number>(target);
const displayedRef = useRef<number>(target);
const frameRef = useRef<number | null>(null);
const tweenRef = useRef<Tween | null>(null);
const cancelFrame = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
tweenRef.current = null;
}, []);
useEffect(() => {
const shown = displayedRef.current;
// non-finite targets pass straight through, no tween at all
if (!Number.isFinite(target)) {
cancelFrame();
displayedRef.current = target;
setDisplayed(target);
return undefined;
}
// already there — the first render never animates
if (target === shown) {
cancelFrame();
return undefined;
}
// no tween engine when the distance is zero or motion is reduced
if (duration <= 0 || prefersReducedMotion()) {
cancelFrame();
displayedRef.current = target;
setDisplayed(target);
return undefined;
}
// retargeting reads the value on screen, so the count keeps its position
cancelFrame();
const from = Number.isFinite(shown) ? shown : 0;
const durationMs = duration;
tweenRef.current = {
from,
to: target,
start: performance.now(),
duration: durationMs,
};
const tick = (now: number) => {
const active = tweenRef.current;
if (!active) return;
const elapsed = now - active.start;
const progress =
active.duration <= 0
? 1
: Math.min(1, Math.max(0, elapsed / active.duration));
const eased = cubicBezierProgress(progress);
const next =
progress >= 1
? active.to
: active.from + (active.to - active.from) * eased;
displayedRef.current = next;
setDisplayed(next);
if (progress >= 1) {
tweenRef.current = null;
frameRef.current = null;
return;
}
frameRef.current = requestAnimationFrame(tick);
};
frameRef.current = requestAnimationFrame(tick);
return cancelFrame;
}, [target, duration, cancelFrame]);
return {
value: displayed,
display: formatAnimatedNumber(displayed, precision, separators),
};
}
/* ------------------------------------------------------------------ */
/* component */
/* ------------------------------------------------------------------ */
export type AnimatedNumberProps = AnimatedNumberOptions & {
value: number;
className?: string;
style?: CSSProperties;
};
export function AnimatedNumber({
value,
duration = C.defaults.duration,
precision = C.defaults.precision,
separators = C.defaults.separators,
className = "",
style,
}: AnimatedNumberProps) {
const { display } = useAnimatedNumber(value, {
duration,
precision,
separators,
});
return (
<span
data-animated-number=""
className={`inline-block text-right ${className}`.trim()}
style={{
minWidth: `${C.formatting.minWidthCh}ch`,
fontVariantNumeric: "tabular-nums",
fontFeatureSettings: '"tnum" 1, "lnum" 1',
whiteSpace: "nowrap",
...style,
}}
>
{display}
</span>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
金额、指标或计数器换值时,用 420ms 的 rAF 补间把旧值数到新值;进度来自手写的 cubic-bezier(0.2, 0.8, 0.2, 1) 曲线(Newton 迭代反解 t)。动画途中再换目标会从当前显示值改道重数,NaN / Infinity 等非有限值原样透传。
- 补间时长
- 420 ms
- 小数位
- 0 dec
- 千分位
- 开启
- 缓动
- cubic-bezier(0.2, 0.8, 0.2, 1)
- 补间
- 420ms · 逐帧
- 格式
- 0 位小数 · 千分位开
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 补间时长严格等于 duration(默认 420ms):200ms / 1200ms 两端都能正常起止;小数位与千分位显示符合 precision / separators。
- 从旧值数到新值时,60–80ms 处的显示值严格大于起始值且严格小于结束值,不是瞬间跳变。
- 结束帧显示值精确等于目标值;没有残留的 rAF,也没有残留的内联 transform / 动画样式。
- 数字使用 tabular-nums 且容器宽度固定:滚动全程 boundingBox 稳定,不抖动、不换行。
- prefers-reduced-motion 时目标变化在 ≤1ms 内直接落到最终值,不启动 rAF。
- 键盘可完成 Demo 里的全部操作(Tab 聚焦、Enter / Space 触发),焦点环清晰可见。
- 0 console 报错;中途切换目标不抛错;卸载后不再更新状态、没有残留的 rAF 回调。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。