diff --git a/.changeset/add-scroll-number-enhance-badge.md b/.changeset/add-scroll-number-enhance-badge.md
new file mode 100644
index 00000000..43defbfb
--- /dev/null
+++ b/.changeset/add-scroll-number-enhance-badge.md
@@ -0,0 +1,5 @@
+---
+"@tiny-design/react": minor
+---
+
+Add ScrollNumber component with animated digit transitions and shortest-path scrolling; integrate into Badge for smooth count animations
diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx
index 8720635a..b2dc604c 100755
--- a/apps/docs/src/routers.tsx
+++ b/apps/docs/src/routers.tsx
@@ -123,6 +123,7 @@ const c = {
popConfirm: ll(() => import('../../../packages/react/src/pop-confirm/index.md'), () => import('../../../packages/react/src/pop-confirm/index.zh_CN.md')),
result: ll(() => import('../../../packages/react/src/result/index.md'), () => import('../../../packages/react/src/result/index.zh_CN.md')),
scrollIndicator: ll(() => import('../../../packages/react/src/scroll-indicator/index.md'), () => import('../../../packages/react/src/scroll-indicator/index.zh_CN.md')),
+ scrollNumber: ll(() => import('../../../packages/react/src/scroll-number/index.md'), () => import('../../../packages/react/src/scroll-number/index.zh_CN.md')),
skeleton: ll(() => import('../../../packages/react/src/skeleton/index.md'), () => import('../../../packages/react/src/skeleton/index.zh_CN.md')),
strengthIndicator: ll(() => import('../../../packages/react/src/strength-indicator/index.md'), () => import('../../../packages/react/src/strength-indicator/index.zh_CN.md')),
backTop: ll(() => import('../../../packages/react/src/back-top/index.md'), () => import('../../../packages/react/src/back-top/index.zh_CN.md')),
@@ -214,6 +215,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
{ title: 'Marquee', route: 'marquee', component: pick(c.marquee, z) },
{ title: 'Popover', route: 'popover', component: pick(c.popover, z) },
{ title: 'Progress', route: 'progress', component: pick(c.progress, z) },
+ { title: 'ScrollNumber', route: 'scroll-number', component: pick(c.scrollNumber, z) },
{ title: 'Statistic', route: 'statistic', component: pick(c.statistic, z) },
{ title: 'Table', route: 'table', component: pick(c.table, z) },
{ title: 'Tag', route: 'tag', component: pick(c.tag, z) },
diff --git a/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap b/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap
index 3b46a5fa..2f07cbc9 100644
--- a/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap
+++ b/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap
@@ -13,7 +13,100 @@ exports[` should match the snapshot 1`] = `
class="ty-badge__count"
style="background-color: rgb(242, 69, 61);"
>
- 5
+
diff --git a/packages/react/src/badge/__tests__/badge.test.tsx b/packages/react/src/badge/__tests__/badge.test.tsx
index d5c50db3..2d9ccad7 100644
--- a/packages/react/src/badge/__tests__/badge.test.tsx
+++ b/packages/react/src/badge/__tests__/badge.test.tsx
@@ -13,14 +13,17 @@ describe('', () => {
expect(container.firstChild).toHaveClass('ty-badge');
});
- it('should render count', () => {
- const { getByText } = render(content
);
- expect(getByText('5')).toBeInTheDocument();
+ it('should render count with ScrollNumber', () => {
+ const { container } = render(content
);
+ expect(container.querySelector('.ty-badge__count')).toBeTruthy();
+ expect(container.querySelector('.ty-badge__scroll-number')).toBeTruthy();
});
it('should render max+ when count exceeds max', () => {
- const { getByText } = render(content
);
- expect(getByText('99+')).toBeInTheDocument();
+ const { container } = render(content
);
+ const suffix = container.querySelector('.ty-scroll-number__suffix');
+ expect(suffix).toBeTruthy();
+ expect(suffix!.textContent).toBe('+');
});
it('should render as dot', () => {
@@ -34,7 +37,15 @@ describe('', () => {
});
it('should show zero when showZero is true', () => {
- const { getByText } = render(content
);
- expect(getByText('0')).toBeInTheDocument();
+ const { container } = render(content
);
+ expect(container.querySelector('.ty-badge__count')).toBeTruthy();
+ expect(container.querySelector('.ty-badge__scroll-number')).toBeTruthy();
+ });
+
+ it('should render string count without ScrollNumber', () => {
+ const { container, getByText } = render(content
);
+ expect(container.querySelector('.ty-badge__count')).toBeTruthy();
+ expect(container.querySelector('.ty-badge__scroll-number')).toBeFalsy();
+ expect(getByText('new')).toBeInTheDocument();
});
});
diff --git a/packages/react/src/badge/badge.tsx b/packages/react/src/badge/badge.tsx
index bb6e0faf..016ff175 100755
--- a/packages/react/src/badge/badge.tsx
+++ b/packages/react/src/badge/badge.tsx
@@ -3,6 +3,7 @@ import classNames from 'classnames';
import warning from '../_utils/warning';
import { ConfigContext } from '../config-provider/config-context';
import { getPrefixCls } from '../_utils/general';
+import ScrollNumber from '../scroll-number';
import { BadgeProps } from './types';
const Badge = React.memo(React.forwardRef((props, ref) => {
@@ -28,16 +29,32 @@ const Badge = React.memo(React.forwardRef((props, r
warning(!dot && processing, 'only dot badge has the processing effect');
const renderCount = () => {
- if (typeof count === 'number' || typeof count === 'string') {
+ if (typeof count === 'number') {
if (count === 0 && !showZero) {
return null;
}
+ const displayValue = count > max ? max : count;
+ const overflowSuffix = count > max ? '+' : undefined;
return (
- {typeof count === 'number' && count > max ? `${max}+` : count}
+
+
+ );
+ } else if (typeof count === 'string') {
+ return (
+
+ {count}
);
} else {
diff --git a/packages/react/src/badge/demo/Dynamic.tsx b/packages/react/src/badge/demo/Dynamic.tsx
new file mode 100644
index 00000000..a7f9563e
--- /dev/null
+++ b/packages/react/src/badge/demo/Dynamic.tsx
@@ -0,0 +1,43 @@
+import { useState } from 'react';
+import { Badge, Button, Switch, Flex } from '@tiny-design/react';
+
+export default function DynamicDemo() {
+ const [count, setCount] = useState(5);
+ const [dot, setDot] = useState(true);
+
+ const spanStyle = {
+ width: '42px',
+ height: '42px',
+ borderRadius: '4px',
+ background: '#eee',
+ display: 'inline-block',
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/badge/index.md b/packages/react/src/badge/index.md
index 9bc51a18..10dee8c2 100755
--- a/packages/react/src/badge/index.md
+++ b/packages/react/src/badge/index.md
@@ -8,6 +8,8 @@ import OverflowDemo from './demo/Overflow';
import OverflowSource from './demo/Overflow.tsx?raw';
import StandaloneDemo from './demo/Standalone';
import StandaloneSource from './demo/Standalone.tsx?raw';
+import DynamicDemo from './demo/Dynamic';
+import DynamicSource from './demo/Dynamic.tsx?raw';
# Badge
@@ -74,6 +76,15 @@ Set `color` to display the dot badge with different colors. `processing` can sho
> Only the dot badge has the `processing` effect.
+
+
+
+
+### Dynamic
+
+Increase or decrease the count with buttons, or toggle the dot with a switch.
+
+
diff --git a/packages/react/src/badge/index.zh_CN.md b/packages/react/src/badge/index.zh_CN.md
index f176c1fb..5effb8a1 100644
--- a/packages/react/src/badge/index.zh_CN.md
+++ b/packages/react/src/badge/index.zh_CN.md
@@ -8,6 +8,8 @@ import OverflowDemo from './demo/Overflow';
import OverflowSource from './demo/Overflow.tsx?raw';
import StandaloneDemo from './demo/Standalone';
import StandaloneSource from './demo/Standalone.tsx?raw';
+import DynamicDemo from './demo/Dynamic';
+import DynamicSource from './demo/Dynamic.tsx?raw';
# Badge
@@ -74,6 +76,15 @@ import { Badge } from 'tiny-design';
> 只有小圆点徽标才有 `processing` 效果。
+
+
+
+
+### 动态变化
+
+通过按钮增减数字,或通过开关切换小圆点。
+
+
diff --git a/packages/react/src/badge/style/_index.scss b/packages/react/src/badge/style/_index.scss
index e58fc578..40366e89 100755
--- a/packages/react/src/badge/style/_index.scss
+++ b/packages/react/src/badge/style/_index.scss
@@ -9,7 +9,8 @@
line-height: 1;
&__count {
- @include badge-base();
+ @include badge-base;
+
min-width: $badge-size;
min-height: $badge-size;
line-height: $badge-size;
@@ -29,13 +30,14 @@
}
&__dot {
- @include badge-base();
+ @include badge-base;
+
width: $badge-dot-size;
height: $badge-dot-size;
line-height: $badge-dot-size;
&_wave {
- &:after {
+ &::after {
content: '';
position: absolute;
top: 0;
@@ -49,6 +51,32 @@
}
}
+ &__scroll-number {
+ display: inline-flex;
+
+ .#{$prefix}-scroll-number__content {
+ font-size: inherit;
+ font-weight: inherit;
+ color: inherit;
+ font-family: inherit;
+ }
+
+ .#{$prefix}-scroll-number__measure {
+ font-size: inherit;
+ font-weight: inherit;
+ }
+
+ .#{$prefix}-scroll-number__suffix {
+ margin-left: 0;
+ font-size: inherit;
+ }
+
+ .#{$prefix}-scroll-number__sr-only,
+ .#{$prefix}-scroll-number__title {
+ display: none;
+ }
+ }
+
&_no-wrap {
.#{$prefix}-badge__count,
.#{$prefix}-badge__dot {
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index f0b6329c..d2313b3e 100755
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -55,6 +55,7 @@ export { default as Radio } from './radio';
export { default as Rate } from './rate';
export { default as Result } from './result';
export { default as ScrollIndicator } from './scroll-indicator';
+export { default as ScrollNumber } from './scroll-number';
export { default as Segmented } from './segmented';
export { default as Select } from './select';
export { default as Skeleton } from './skeleton';
diff --git a/packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap b/packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap
new file mode 100644
index 00000000..d04dac32
--- /dev/null
+++ b/packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap
@@ -0,0 +1,648 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` should match the snapshot 1`] = `
+
+
+
+`;
diff --git a/packages/react/src/scroll-number/__tests__/scroll-number.test.tsx b/packages/react/src/scroll-number/__tests__/scroll-number.test.tsx
new file mode 100644
index 00000000..23bd735a
--- /dev/null
+++ b/packages/react/src/scroll-number/__tests__/scroll-number.test.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import ScrollNumber from '../index';
+
+describe('', () => {
+ it('should match the snapshot', () => {
+ const { asFragment } = render();
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it('should render with correct class name', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('ty-scroll-number');
+ });
+
+ it('should render digit columns for each digit', () => {
+ const { container } = render();
+ const digits = container.querySelectorAll('.ty-scroll-number__digit');
+ expect(digits).toHaveLength(2);
+ });
+
+ it('should render cells in each digit column', () => {
+ const { container } = render();
+ const cells = container.querySelectorAll('.ty-scroll-number__digit-cell');
+ expect(cells.length).toBeGreaterThanOrEqual(10);
+ });
+
+ it('should render separator for formatted numbers', () => {
+ const { container } = render();
+ const separators = container.querySelectorAll('.ty-scroll-number__separator');
+ expect(separators).toHaveLength(1);
+ expect(separators[0].textContent).toBe(',');
+ });
+
+ it('should render title when provided', () => {
+ const { container } = render();
+ const title = container.querySelector('.ty-scroll-number__title');
+ expect(title).toBeTruthy();
+ expect(title!.textContent).toBe('Users');
+ });
+
+ it('should render prefix and suffix', () => {
+ const { container } = render();
+ expect(container.querySelector('.ty-scroll-number__prefix')!.textContent).toBe('$');
+ expect(container.querySelector('.ty-scroll-number__suffix')!.textContent).toBe('USD');
+ });
+
+ it('should handle precision', () => {
+ const { container } = render();
+ const digits = container.querySelectorAll('.ty-scroll-number__digit');
+ // 3.10 has digits: 3, 1, 0
+ expect(digits).toHaveLength(3);
+ });
+
+ it('should handle custom prefixCls', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('custom');
+ });
+
+ it('should handle string value', () => {
+ const { container } = render();
+ const digits = container.querySelectorAll('.ty-scroll-number__digit');
+ expect(digits).toHaveLength(2);
+ });
+
+ it('should render empty when value is undefined', () => {
+ const { container } = render();
+ const digits = container.querySelectorAll('.ty-scroll-number__digit');
+ expect(digits).toHaveLength(0);
+ });
+
+ it('should render negative numbers with separator for minus sign', () => {
+ const { container } = render();
+ const separators = container.querySelectorAll('.ty-scroll-number__separator');
+ expect(separators[0].textContent).toBe('-');
+ });
+
+ it('should forward ref', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ });
+});
diff --git a/packages/react/src/scroll-number/demo/Basic.tsx b/packages/react/src/scroll-number/demo/Basic.tsx
new file mode 100644
index 00000000..933feecd
--- /dev/null
+++ b/packages/react/src/scroll-number/demo/Basic.tsx
@@ -0,0 +1,21 @@
+import React, { useState } from 'react';
+import { ScrollNumber, Button, Flex } from '@tiny-design/react';
+
+export default function BasicDemo() {
+ const [value, setValue] = useState(1234);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/scroll-number/demo/CustomStyle.tsx b/packages/react/src/scroll-number/demo/CustomStyle.tsx
new file mode 100644
index 00000000..d82ef944
--- /dev/null
+++ b/packages/react/src/scroll-number/demo/CustomStyle.tsx
@@ -0,0 +1,31 @@
+import React, { useState } from 'react';
+import { ScrollNumber, Button, Flex } from '@tiny-design/react';
+
+export default function CustomStyleDemo() {
+ const [value, setValue] = useState(88888);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/scroll-number/demo/Duration.tsx b/packages/react/src/scroll-number/demo/Duration.tsx
new file mode 100644
index 00000000..6a05e4fd
--- /dev/null
+++ b/packages/react/src/scroll-number/demo/Duration.tsx
@@ -0,0 +1,28 @@
+import React, { useState } from 'react';
+import { ScrollNumber, Button, Flex } from '@tiny-design/react';
+
+export default function DurationDemo() {
+ const [value, setValue] = useState(100);
+
+ const handleClick = () => setValue(Math.floor(Math.random() * 10000));
+
+ return (
+
+ );
+}
diff --git a/packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx b/packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx
new file mode 100644
index 00000000..20023927
--- /dev/null
+++ b/packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx
@@ -0,0 +1,21 @@
+import React, { useState } from 'react';
+import { ScrollNumber, Button, Flex } from '@tiny-design/react';
+
+export default function TitlePrefixSuffixDemo() {
+ const [users, setUsers] = useState(2846);
+ const [rate, setRate] = useState(93.12);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/scroll-number/index.md b/packages/react/src/scroll-number/index.md
new file mode 100644
index 00000000..4c2a623a
--- /dev/null
+++ b/packages/react/src/scroll-number/index.md
@@ -0,0 +1,80 @@
+import BasicDemo from './demo/Basic';
+import BasicSource from './demo/Basic.tsx?raw';
+import TitlePrefixSuffixDemo from './demo/TitlePrefixSuffix';
+import TitlePrefixSuffixSource from './demo/TitlePrefixSuffix.tsx?raw';
+import DurationDemo from './demo/Duration';
+import DurationSource from './demo/Duration.tsx?raw';
+import CustomStyleDemo from './demo/CustomStyle';
+import CustomStyleSource from './demo/CustomStyle.tsx?raw';
+
+# ScrollNumber
+
+Animate number transitions with a vertical rolling effect. Each digit scrolls independently when the value changes, creating a mechanical counter effect.
+
+## Scenario
+
+Used in dashboards, stat counters, badges, and anywhere numbers change dynamically and the transition should be visually engaging.
+
+## Usage
+
+```jsx
+import { ScrollNumber } from 'tiny-design';
+```
+
+## Examples
+
+
+
+
+
+### Basic
+
+Click the buttons to change the value and see the scroll animation.
+
+
+
+
+
+
+### Animation Duration
+
+Compare different animation speeds side by side.
+
+
+
+
+
+
+
+
+### Title, Prefix & Suffix
+
+Display with title, prefix, suffix, and precision like a Statistic component.
+
+
+
+
+
+
+### Custom Style
+
+Customize font size, color, and separator via `valueStyle` and `groupSeparator`.
+
+
+
+
+
+
+
+## API
+
+| Property | Description | Type | Default |
+| --- | --- | --- | --- |
+| value | The number to display | `number \| string` | - |
+| title | Title displayed above the value | `ReactNode` | - |
+| duration | Animation duration in milliseconds | `number` | `300` |
+| precision | Number of decimal places | `number` | - |
+| groupSeparator | Thousands separator character | `string` | `','` |
+| prefix | Content before the number | `ReactNode` | - |
+| suffix | Content after the number | `ReactNode` | - |
+| valueStyle | Custom style for the value container | `CSSProperties` | - |
diff --git a/packages/react/src/scroll-number/index.tsx b/packages/react/src/scroll-number/index.tsx
new file mode 100644
index 00000000..4d3ac8a0
--- /dev/null
+++ b/packages/react/src/scroll-number/index.tsx
@@ -0,0 +1,3 @@
+import ScrollNumber from './scroll-number';
+
+export default ScrollNumber;
diff --git a/packages/react/src/scroll-number/index.zh_CN.md b/packages/react/src/scroll-number/index.zh_CN.md
new file mode 100644
index 00000000..6bff3edb
--- /dev/null
+++ b/packages/react/src/scroll-number/index.zh_CN.md
@@ -0,0 +1,80 @@
+import BasicDemo from './demo/Basic';
+import BasicSource from './demo/Basic.tsx?raw';
+import TitlePrefixSuffixDemo from './demo/TitlePrefixSuffix';
+import TitlePrefixSuffixSource from './demo/TitlePrefixSuffix.tsx?raw';
+import DurationDemo from './demo/Duration';
+import DurationSource from './demo/Duration.tsx?raw';
+import CustomStyleDemo from './demo/CustomStyle';
+import CustomStyleSource from './demo/CustomStyle.tsx?raw';
+
+# ScrollNumber 滚动数字
+
+通过垂直滚动效果展示数字变化。每个数字位独立滚动,形成机械计数器效果。
+
+## 使用场景
+
+适用于仪表盘、统计计数、徽标等需要动态数字变化且需要视觉吸引力的场景。
+
+## 使用方式
+
+```jsx
+import { ScrollNumber } from 'tiny-design';
+```
+
+## 代码演示
+
+
+
+
+
+### 基本用法
+
+点击按钮改变数值,查看滚动动画效果。
+
+
+
+
+
+
+### 动画时长
+
+对比不同动画速度的效果。
+
+
+
+
+
+
+
+
+### 标题、前缀和后缀
+
+搭配标题、前缀、后缀和精度使用,类似 Statistic 组件。
+
+
+
+
+
+
+### 自定义样式
+
+通过 `valueStyle` 和 `groupSeparator` 自定义字号、颜色和分隔符。
+
+
+
+
+
+
+
+## API
+
+| 属性 | 说明 | 类型 | 默认值 |
+| --- | --- | --- | --- |
+| value | 显示的数值 | `number \| string` | - |
+| title | 数值上方显示的标题 | `ReactNode` | - |
+| duration | 动画持续时间(毫秒) | `number` | `300` |
+| precision | 小数位数 | `number` | - |
+| groupSeparator | 千位分隔符 | `string` | `','` |
+| prefix | 数值前缀内容 | `ReactNode` | - |
+| suffix | 数值后缀内容 | `ReactNode` | - |
+| valueStyle | 数值容器的自定义样式 | `CSSProperties` | - |
diff --git a/packages/react/src/scroll-number/scroll-number.tsx b/packages/react/src/scroll-number/scroll-number.tsx
new file mode 100644
index 00000000..8e2641b8
--- /dev/null
+++ b/packages/react/src/scroll-number/scroll-number.tsx
@@ -0,0 +1,207 @@
+import React, { useContext, useMemo, useRef, useState, useCallback, useEffect } from 'react';
+import classNames from 'classnames';
+import { ConfigContext } from '../config-provider/config-context';
+import { getPrefixCls } from '../_utils/general';
+import { ScrollNumberProps } from './types';
+
+// Build a long enough column so position can accumulate without running out.
+// Range: -20 to 29 (50 cells). Position resets when it drifts beyond ±10.
+const COLUMN_CELLS: number[] = [];
+for (let i = -20; i <= 29; i++) {
+ COLUMN_CELLS.push(((i % 10) + 10) % 10);
+}
+const COL_OFFSET = 20; // cell at index 0 represents virtual position -20
+
+const formatValue = (
+ value: number | string | undefined,
+ precision?: number,
+ groupSeparator?: string
+): string => {
+ if (value === undefined) return '';
+ if (typeof value === 'string') return value;
+
+ let val = precision !== undefined ? value.toFixed(precision) : String(value);
+
+ if (groupSeparator) {
+ const parts = val.split('.');
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator);
+ val = parts.join('.');
+ }
+
+ return val;
+};
+
+interface ScrollDigitProps {
+ digit: number;
+ cellHeight: number;
+ duration: number;
+ prefixCls: string;
+}
+
+const ScrollDigit: React.FC = ({ digit, cellHeight, duration, prefixCls }) => {
+ const prevDigit = useRef(digit);
+ const positionRef = useRef(digit);
+ const columnRef = useRef(null);
+
+ // When cellHeight becomes available or changes, reposition without animation.
+ // Initialize to 0 so the effect fires on mount even if cellHeight is already > 0.
+ const prevCellHeight = useRef(0);
+ useEffect(() => {
+ if (cellHeight > 0 && cellHeight !== prevCellHeight.current) {
+ prevCellHeight.current = cellHeight;
+ if (columnRef.current) {
+ columnRef.current.style.transition = 'none';
+ columnRef.current.style.transform = `translateY(${-(positionRef.current + COL_OFFSET) * cellHeight}px)`;
+ }
+ }
+ }, [cellHeight]);
+
+ useEffect(() => {
+ const prev = prevDigit.current;
+ if (prev === digit) return;
+
+ // Take the shortest path across the 0/9 boundary
+ const forward = (digit - prev + 10) % 10;
+ const backward = (prev - digit + 10) % 10;
+
+ let newPosition: number;
+ if (forward <= backward) {
+ newPosition = positionRef.current + forward;
+ } else {
+ newPosition = positionRef.current - backward;
+ }
+
+ positionRef.current = newPosition;
+ prevDigit.current = digit;
+
+ if (columnRef.current && cellHeight > 0) {
+ columnRef.current.style.transition = `transform ${duration}ms cubic-bezier(0.12, 0.4, 0.29, 1.46)`;
+ columnRef.current.style.transform = `translateY(${-(newPosition + COL_OFFSET) * cellHeight}px)`;
+ }
+ }, [digit, cellHeight, duration]);
+
+ const handleTransitionEnd = useCallback(
+ (e: React.TransitionEvent) => {
+ // Ignore bubbled events from children
+ if (e.target !== e.currentTarget) return;
+ // After animation, silently reset to canonical [0..9] if drifted too far
+ const pos = positionRef.current;
+ if (pos < -10 || pos > 19) {
+ const canonical = ((pos % 10) + 10) % 10;
+ positionRef.current = canonical;
+ if (columnRef.current) {
+ columnRef.current.style.transition = 'none';
+ columnRef.current.style.transform = `translateY(${-(canonical + COL_OFFSET) * cellHeight}px)`;
+ }
+ }
+ },
+ [cellHeight]
+ );
+
+ return (
+
+
+ {COLUMN_CELLS.map((n, i) => (
+
+ {n}
+
+ ))}
+
+
+ );
+};
+
+const ScrollNumber = React.forwardRef((props, ref) => {
+ const {
+ value,
+ title,
+ duration = 300,
+ precision,
+ groupSeparator = ',',
+ prefix,
+ suffix,
+ valueStyle,
+ prefixCls: customisedCls,
+ className,
+ style,
+ ...otherProps
+ } = props;
+
+ const configContext = useContext(ConfigContext);
+ const prefixCls = getPrefixCls('scroll-number', configContext.prefixCls, customisedCls);
+ const cls = classNames(prefixCls, className);
+
+ const measureRef = useRef(null);
+ const [cellHeight, setCellHeight] = useState(0);
+
+ const measure = useCallback(() => {
+ if (measureRef.current) {
+ const height = measureRef.current.offsetHeight;
+ if (height > 0) {
+ setCellHeight(height);
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ measure();
+ }, [measure, valueStyle]);
+
+ const chars = useMemo(() => {
+ if (value === undefined) return [];
+ const formatted =
+ typeof value === 'string' ? value : formatValue(value, precision, groupSeparator);
+ return formatted.split('');
+ }, [value, precision, groupSeparator]);
+
+ return (
+
+ {title &&
{title}
}
+
+ {prefix && {prefix}}
+
+ {chars.map((char, index) => {
+ // Key from the right so adding a new leading digit doesn't
+ // shift existing digit elements and trigger unwanted transitions.
+ const key = chars.length - index;
+ const digit = parseInt(char, 10);
+ if (!isNaN(digit)) {
+ return (
+
+ );
+ }
+ return (
+
+ {char}
+
+ );
+ })}
+
+ {suffix && {suffix}}
+
+ 0
+
+
+
+ {value !== undefined ? formatValue(value, precision, groupSeparator) : ''}
+
+
+ );
+});
+
+ScrollNumber.displayName = 'ScrollNumber';
+export default ScrollNumber;
diff --git a/packages/react/src/scroll-number/style/_index.scss b/packages/react/src/scroll-number/style/_index.scss
new file mode 100644
index 00000000..b34e8d47
--- /dev/null
+++ b/packages/react/src/scroll-number/style/_index.scss
@@ -0,0 +1,93 @@
+@use '../../style/variables' as *;
+
+.#{$prefix}-scroll-number {
+ display: inline-block;
+ position: relative;
+
+ &__title {
+ margin-bottom: 4px;
+ color: var(--ty-color-text-secondary, #{$gray-600});
+ font-size: var(--ty-font-size-sm);
+ }
+
+ &__content {
+ display: flex;
+ align-items: baseline;
+ color: var(--ty-color-text, #{$gray-900});
+ font-size: 24px;
+ font-weight: 600;
+ font-family: $font-family-sans-serif;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &__prefix {
+ margin-right: 4px;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ &__suffix {
+ margin-left: 4px;
+ font-size: var(--ty-font-size-base);
+ display: inline-flex;
+ align-items: center;
+ }
+
+ &__value {
+ display: inline-flex;
+ overflow: hidden;
+ }
+
+ &__digit {
+ display: inline-block;
+ overflow: hidden;
+ }
+
+ &__digit-column {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__digit-cell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ }
+
+ &__separator {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ &__measure {
+ position: absolute;
+ visibility: hidden;
+ pointer-events: none;
+ font-size: inherit;
+ font-weight: inherit;
+ font-family: inherit;
+ line-height: inherit;
+ }
+
+ &__sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .#{$prefix}-scroll-number {
+ .#{$prefix}-scroll-number__digit-column {
+ transition-duration: 0s !important;
+ }
+ }
+}
diff --git a/packages/react/src/scroll-number/style/index.tsx b/packages/react/src/scroll-number/style/index.tsx
new file mode 100644
index 00000000..67aac616
--- /dev/null
+++ b/packages/react/src/scroll-number/style/index.tsx
@@ -0,0 +1 @@
+import './index.scss';
diff --git a/packages/react/src/scroll-number/types.ts b/packages/react/src/scroll-number/types.ts
new file mode 100644
index 00000000..bd2fda95
--- /dev/null
+++ b/packages/react/src/scroll-number/types.ts
@@ -0,0 +1,23 @@
+import React from 'react';
+import { BaseProps } from '../_utils/props';
+
+export interface ScrollNumberProps
+ extends BaseProps,
+ Omit, 'title' | 'prefix'> {
+ /** The numeric value to display */
+ value?: number | string;
+ /** Title displayed above the value */
+ title?: React.ReactNode;
+ /** Animation duration in milliseconds */
+ duration?: number;
+ /** Number of decimal places to display */
+ precision?: number;
+ /** Thousands separator character */
+ groupSeparator?: string;
+ /** Prefix node rendered before the number */
+ prefix?: React.ReactNode;
+ /** Suffix node rendered after the number */
+ suffix?: React.ReactNode;
+ /** Custom style applied to the value container */
+ valueStyle?: React.CSSProperties;
+}
diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss
index e602a183..cadd6251 100644
--- a/packages/react/src/style/_component.scss
+++ b/packages/react/src/style/_component.scss
@@ -52,6 +52,7 @@
@use "../result/style/index" as *;
@use "../native-select/style/index" as *;
@use "../scroll-indicator/style/index" as *;
+@use "../scroll-number/style/index" as *;
@use "../segmented/style/index" as *;
@use "../select/style/index" as *;
@use "../skeleton/style/index" as *;