diff --git a/.changeset/add-scroll-number-enhance-badge.md b/.changeset/add-scroll-number-enhance-badge.md
new file mode 100644
index 00000000..ddc63d77
--- /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. Add TextLoop component for cycling through children with vertical slide transitions.
diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx
index 8720635a..35be5c89 100755
--- a/apps/docs/src/routers.tsx
+++ b/apps/docs/src/routers.tsx
@@ -90,6 +90,7 @@ const c = {
statistic: ll(() => import('../../../packages/react/src/statistic/index.md'), () => import('../../../packages/react/src/statistic/index.zh_CN.md')),
table: ll(() => import('../../../packages/react/src/table/index.md'), () => import('../../../packages/react/src/table/index.zh_CN.md')),
tag: ll(() => import('../../../packages/react/src/tag/index.md'), () => import('../../../packages/react/src/tag/index.zh_CN.md')),
+ textLoop: ll(() => import('../../../packages/react/src/text-loop/index.md'), () => import('../../../packages/react/src/text-loop/index.zh_CN.md')),
timeline: ll(() => import('../../../packages/react/src/timeline/index.md'), () => import('../../../packages/react/src/timeline/index.zh_CN.md')),
tooltip: ll(() => import('../../../packages/react/src/tooltip/index.md'), () => import('../../../packages/react/src/tooltip/index.zh_CN.md')),
tree: ll(() => import('../../../packages/react/src/tree/index.md'), () => import('../../../packages/react/src/tree/index.zh_CN.md')),
@@ -123,6 +124,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,9 +216,11 @@ 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) },
+ { title: 'TextLoop', route: 'text-loop', component: pick(c.textLoop, z) },
{ title: 'Timeline', route: 'timeline', component: pick(c.timeline, z) },
{ title: 'Tooltip', route: 'tooltip', component: pick(c.tooltip, z) },
{ title: 'Tree', route: 'tree', component: pick(c.tree, z) },
diff --git a/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap b/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap
index f8216353..e7cc48c5 100644
--- a/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap
+++ b/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap
@@ -6,7 +6,9 @@ exports[` should match the snapshot 1`] = `
class="ty-alert ty-alert_info ty-undefined-enter"
role="alert"
>
-
diff --git a/packages/react/src/alert/alert.tsx b/packages/react/src/alert/alert.tsx
index a5d26386..f868c8de 100755
--- a/packages/react/src/alert/alert.tsx
+++ b/packages/react/src/alert/alert.tsx
@@ -76,7 +76,7 @@ const Alert = React.forwardRef((props, forwardedRef)
else if (forwardedRef) forwardedRef.current = node;
}}>
{icon && renderIcon()}
-
+
{title &&
{title}
}
{children}
diff --git a/packages/react/src/alert/demo/LoopBanner.tsx b/packages/react/src/alert/demo/LoopBanner.tsx
new file mode 100644
index 00000000..c4d9d246
--- /dev/null
+++ b/packages/react/src/alert/demo/LoopBanner.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Alert, Marquee } from '@tiny-design/react';
+
+export default function LoopBannerDemo() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/react/src/alert/index.md b/packages/react/src/alert/index.md
index 87a97007..7c3ba874 100755
--- a/packages/react/src/alert/index.md
+++ b/packages/react/src/alert/index.md
@@ -10,6 +10,8 @@ import TitleDemo from './demo/Title';
import TitleSource from './demo/Title.tsx?raw';
import TypeDemo from './demo/Type';
import TypeSource from './demo/Type.tsx?raw';
+import LoopBannerDemo from './demo/LoopBanner';
+import LoopBannerSource from './demo/LoopBanner.tsx?raw';
# Alert
@@ -85,6 +87,15 @@ Replace the default icon with customized text.
+
+
+
+### Loop Banner
+
+Combine with `Marquee` component to create a scrolling banner alert. The text pauses on hover.
+
+
+
diff --git a/packages/react/src/alert/style/_index.scss b/packages/react/src/alert/style/_index.scss
index 5094ae3b..3d117336 100755
--- a/packages/react/src/alert/style/_index.scss
+++ b/packages/react/src/alert/style/_index.scss
@@ -20,6 +20,11 @@
margin-top: 14px;
}
+ &__content {
+ flex: 1;
+ overflow: hidden;
+ }
+
&__title {
box-sizing: border-box;
margin: 0;
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..8f6f5bae 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,339 @@ 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..13d68dcf 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';
@@ -71,6 +72,7 @@ export { default as Switch } from './switch';
export { default as Table } from './table';
export { default as Tabs } from './tabs';
export { default as Tag } from './tag';
+export { default as TextLoop } from './text-loop';
export { default as Textarea } from './textarea';
export { default as Timeline } from './timeline';
export { default as TimePicker } from './time-picker';
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..4a11ebaf 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 *;
@@ -68,6 +69,7 @@
@use "../table/style/index" as *;
@use "../tabs/style/index" as *;
@use "../tag/style/index" as *;
+@use "../text-loop/style/index" as *;
@use "../textarea/style/index" as *;
@use "../timeline/style/index" as *;
@use "../time-picker/style/index" as *;
diff --git a/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap b/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap
new file mode 100644
index 00000000..e8476560
--- /dev/null
+++ b/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` should match the snapshot 1`] = `
+
+
+
+
+
+ A
+
+
+
+
+ B
+
+
+
+
+ A
+
+
+
+
+
+`;
diff --git a/packages/react/src/text-loop/__tests__/text-loop.test.tsx b/packages/react/src/text-loop/__tests__/text-loop.test.tsx
new file mode 100644
index 00000000..3d8741ed
--- /dev/null
+++ b/packages/react/src/text-loop/__tests__/text-loop.test.tsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react';
+import TextLoop from '../index';
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+describe('', () => {
+ it('should match the snapshot', () => {
+ const { asFragment } = render(
+
+ A
+ B
+
+ );
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it('should render an empty container with no children', () => {
+ const { container } = render();
+ expect(container.querySelector('.ty-text-loop')).toBeTruthy();
+ expect(container.querySelector('.ty-text-loop__item')).toBeFalsy();
+ });
+
+ it('should render statically with one child', () => {
+ const { container } = render(
+
+ Only one
+
+ );
+ expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(1);
+ });
+
+ it('should render all children plus a duplicate first child when infinite', () => {
+ const { container } = render(
+
+ A
+ B
+ C
+
+ );
+ expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(4);
+ });
+
+ it('should not duplicate first child when infinite is false', () => {
+ const { container } = render(
+
+ A
+ B
+ C
+
+ );
+ expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(3);
+ });
+
+ it('should cycle to next child after interval', () => {
+ const { container } = render(
+
+ A
+ B
+
+ );
+ const track = container.querySelector('.ty-text-loop__track') as HTMLElement;
+ const initialTransform = track.style.transform;
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(track.style.transform).not.toBe(initialTransform);
+ });
+
+ it('should pause cycling on hover and resume on leave', () => {
+ const { container } = render(
+
+ A
+ B
+ C
+
+ );
+ const root = container.firstChild as HTMLElement;
+ const track = container.querySelector('.ty-text-loop__track') as HTMLElement;
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ const transformAfterFirst = track.style.transform;
+
+ fireEvent.mouseEnter(root);
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+ expect(track.style.transform).toBe(transformAfterFirst);
+
+ fireEvent.mouseLeave(root);
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(track.style.transform).not.toBe(transformAfterFirst);
+ });
+
+ it('should stop after one cycle when infinite is false', () => {
+ const { container } = render(
+
+ A
+ B
+
+ );
+ const track = container.querySelector('.ty-text-loop__track') as HTMLElement;
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ const transformAtLast = track.style.transform;
+
+ fireEvent.transitionEnd(track);
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(track.style.transform).toBe(transformAtLast);
+ });
+
+ it('should render track element', () => {
+ const { container } = render(
+
+ A
+ B
+
+ );
+ const track = container.querySelector('.ty-text-loop__track');
+ expect(track).toBeInTheDocument();
+ });
+
+ it('should have aria-live attribute', () => {
+ const { container } = render(
+
+ A
+ B
+
+ );
+ expect(container.firstChild).toHaveAttribute('aria-live', 'polite');
+ });
+
+ it('should accept custom className and style', () => {
+ const { container } = render(
+
+ A
+ B
+
+ );
+ expect(container.firstChild).toHaveClass('ty-text-loop', 'custom');
+ expect(container.firstChild).toHaveStyle({ color: 'red' });
+ });
+});
diff --git a/packages/react/src/text-loop/demo/alert-banner.tsx b/packages/react/src/text-loop/demo/alert-banner.tsx
new file mode 100644
index 00000000..94d4b4a6
--- /dev/null
+++ b/packages/react/src/text-loop/demo/alert-banner.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { Alert, TextLoop } from '@tiny-design/react';
+
+export default function AlertBannerDemo() {
+ return (
+
+
+ Alert message content 1
+ Alert message content 2
+ Alert message content 3
+
+
+ );
+}
diff --git a/packages/react/src/text-loop/demo/basic.tsx b/packages/react/src/text-loop/demo/basic.tsx
new file mode 100644
index 00000000..2b3850f2
--- /dev/null
+++ b/packages/react/src/text-loop/demo/basic.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { TextLoop } from '@tiny-design/react';
+
+export default function BasicDemo() {
+ return (
+
+ February is the shortest month of the year.
+ The quick brown fox jumps over the lazy dog.
+ Always code as if the guy who ends up maintaining it will be a violent psychopath.
+
+ );
+}
diff --git a/packages/react/src/text-loop/demo/direction.tsx b/packages/react/src/text-loop/demo/direction.tsx
new file mode 100644
index 00000000..33050af7
--- /dev/null
+++ b/packages/react/src/text-loop/demo/direction.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { TextLoop, Space } from '@tiny-design/react';
+
+const items = ['Spring', 'Summer', 'Autumn', 'Winter'];
+
+export default function DirectionDemo() {
+ return (
+
+ {(['up', 'down'] as const).map((dir) => (
+
+ {dir}
+
+ {items.map((item) => (
+ {item}
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/react/src/text-loop/demo/interval.tsx b/packages/react/src/text-loop/demo/interval.tsx
new file mode 100644
index 00000000..b79ef0e5
--- /dev/null
+++ b/packages/react/src/text-loop/demo/interval.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { TextLoop } from '@tiny-design/react';
+
+export default function IntervalDemo() {
+ return (
+
+ Fast rotation — 1.5s interval
+ Useful for short, glanceable messages
+ Keeps the user's attention
+
+ );
+}
diff --git a/packages/react/src/text-loop/demo/once.tsx b/packages/react/src/text-loop/demo/once.tsx
new file mode 100644
index 00000000..13ca8acf
--- /dev/null
+++ b/packages/react/src/text-loop/demo/once.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { TextLoop } from '@tiny-design/react';
+
+export default function OnceDemo() {
+ return (
+
+ Step 1: Install dependencies
+ Step 2: Configure your project
+ Step 3: Start building
+
+ );
+}
diff --git a/packages/react/src/text-loop/index.md b/packages/react/src/text-loop/index.md
new file mode 100644
index 00000000..1a824217
--- /dev/null
+++ b/packages/react/src/text-loop/index.md
@@ -0,0 +1,90 @@
+import BasicDemo from './demo/basic';
+import BasicSource from './demo/basic.tsx?raw';
+import AlertBannerDemo from './demo/alert-banner';
+import AlertBannerSource from './demo/alert-banner.tsx?raw';
+import DirectionDemo from './demo/direction';
+import DirectionSource from './demo/direction.tsx?raw';
+import IntervalDemo from './demo/interval';
+import IntervalSource from './demo/interval.tsx?raw';
+import OnceDemo from './demo/once';
+import OnceSource from './demo/once.tsx?raw';
+
+# TextLoop
+
+Cycle through a set of children one at a time with a slide transition.
+
+## Scenario
+
+- Loop through notification messages in a banner.
+- Rotate through tips or announcements.
+
+## Usage
+
+```jsx
+import { TextLoop } from '@tiny-design/react';
+```
+
+## Examples
+
+
+
+
+
+### Basic
+
+Cycles through children vertically every 3 seconds. Pauses on hover.
+
+
+
+
+
+
+### Direction
+
+Use `direction` to control the cycling direction: `up` or `down`.
+
+
+
+
+
+
+### Custom Interval
+
+Set `interval` to control how long each item stays visible (in milliseconds).
+
+
+
+
+
+
+
+
+### Play Once
+
+Set `infinite={false}` to stop after one full cycle, ending on the last item.
+
+
+
+
+
+
+### Alert Banner
+
+Combine with `Alert` to cycle through notification messages.
+
+
+
+
+
+
+
+## API
+
+| Property | Description | Type | Default |
+| ------------ | ---------------------------------------- | --------------------------------------------- | ------- |
+| interval | time each item stays visible (ms) | number | 3000 |
+| pauseOnHover | pause cycling on hover | boolean | true |
+| infinite | loop infinitely or stop after one cycle | boolean | true |
+| direction | cycling direction | `up` \| `down` | `up` |
+| style | style object of container | CSSProperties | - |
+| className | className of container | string | - |
diff --git a/packages/react/src/text-loop/index.tsx b/packages/react/src/text-loop/index.tsx
new file mode 100644
index 00000000..c96d905c
--- /dev/null
+++ b/packages/react/src/text-loop/index.tsx
@@ -0,0 +1,3 @@
+import TextLoop from './text-loop';
+
+export default TextLoop;
diff --git a/packages/react/src/text-loop/index.zh_CN.md b/packages/react/src/text-loop/index.zh_CN.md
new file mode 100644
index 00000000..9d8fe62a
--- /dev/null
+++ b/packages/react/src/text-loop/index.zh_CN.md
@@ -0,0 +1,90 @@
+import BasicDemo from './demo/basic';
+import BasicSource from './demo/basic.tsx?raw';
+import AlertBannerDemo from './demo/alert-banner';
+import AlertBannerSource from './demo/alert-banner.tsx?raw';
+import DirectionDemo from './demo/direction';
+import DirectionSource from './demo/direction.tsx?raw';
+import IntervalDemo from './demo/interval';
+import IntervalSource from './demo/interval.tsx?raw';
+import OnceDemo from './demo/once';
+import OnceSource from './demo/once.tsx?raw';
+
+# TextLoop 文本轮播
+
+逐一循环展示子元素,带有滑动过渡动画。
+
+## Scenario
+
+- 在横幅中循环展示通知消息。
+- 轮播展示提示或公告。
+
+## Usage
+
+```jsx
+import { TextLoop } from '@tiny-design/react';
+```
+
+## 示例
+
+
+
+
+
+### 基础用法
+
+每隔3秒垂直循环切换子元素。鼠标悬停时暂停。
+
+
+
+
+
+
+### 方向
+
+使用 `direction` 控制循环方向:`up` 或 `down`。
+
+
+
+
+
+
+### 自定义间隔
+
+设置 `interval` 控制每个项目的展示时长(毫秒)。
+
+
+
+
+
+
+
+
+### 播放一次
+
+设置 `infinite={false}`,播放完一轮后停在最后一项。
+
+
+
+
+
+
+### 警告横幅
+
+结合 `Alert` 组件循环展示通知消息。
+
+
+
+
+
+
+
+## API
+
+| Property | Description | Type | Default |
+| ------------ | ---------------------------------------- | --------------------------------------------- | ------- |
+| interval | time each item stays visible (ms) | number | 3000 |
+| pauseOnHover | pause cycling on hover | boolean | true |
+| infinite | loop infinitely or stop after one cycle | boolean | true |
+| direction | cycling direction | `up` \| `down` | `up` |
+| style | style object of container | CSSProperties | - |
+| className | className of container | string | - |
diff --git a/packages/react/src/text-loop/style/_index.scss b/packages/react/src/text-loop/style/_index.scss
new file mode 100644
index 00000000..341c299f
--- /dev/null
+++ b/packages/react/src/text-loop/style/_index.scss
@@ -0,0 +1,15 @@
+@use '../../style/variables' as *;
+
+.#{$prefix}-text-loop {
+ overflow: hidden;
+ position: relative;
+
+ &__track {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__item {
+ flex-shrink: 0;
+ }
+}
diff --git a/packages/react/src/text-loop/style/index.tsx b/packages/react/src/text-loop/style/index.tsx
new file mode 100644
index 00000000..dca5d2a0
--- /dev/null
+++ b/packages/react/src/text-loop/style/index.tsx
@@ -0,0 +1 @@
+import './_index.scss';
diff --git a/packages/react/src/text-loop/text-loop.tsx b/packages/react/src/text-loop/text-loop.tsx
new file mode 100644
index 00000000..fbdb96ab
--- /dev/null
+++ b/packages/react/src/text-loop/text-loop.tsx
@@ -0,0 +1,178 @@
+import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react';
+import classNames from 'classnames';
+import { ConfigContext } from '../config-provider/config-context';
+import { getPrefixCls } from '../_utils/general';
+import { TextLoopProps } from './types';
+
+const TextLoop = React.forwardRef((props, forwardedRef) => {
+ const {
+ interval = 3000,
+ pauseOnHover = true,
+ infinite = true,
+ direction = 'up',
+ prefixCls: customisedCls,
+ className,
+ style,
+ children,
+ ...otherProps
+ } = props;
+
+ const configContext = useContext(ConfigContext);
+ const prefixCls = getPrefixCls('text-loop', configContext.prefixCls, customisedCls);
+
+ const isReverse = direction === 'down';
+
+ const childArray = useMemo(() => React.Children.toArray(children), [children]);
+ const count = childArray.length;
+
+ // Build items list with duplicate for seamless infinite loop
+ const items = useMemo(() => {
+ if (count <= 1 || !infinite) return childArray;
+ if (isReverse) {
+ // Prepend duplicate of last child
+ return [childArray[count - 1], ...childArray];
+ }
+ // Append duplicate of first child
+ return [...childArray, childArray[0]];
+ }, [childArray, count, infinite, isReverse]);
+
+ const getInitialIndex = useCallback(() => {
+ if (count <= 1) return 0;
+ if (isReverse) return infinite ? count : count - 1;
+ return 0;
+ }, [count, isReverse, infinite]);
+
+ const [index, setIndex] = useState(getInitialIndex);
+ const [itemSize, setItemSize] = useState(0);
+ const [transitioning, setTransitioning] = useState(true);
+
+ const firstItemRef = useRef(null);
+ const timerRef = useRef | null>(null);
+ const isPausedRef = useRef(false);
+
+ // Measure the first item's height
+ const measure = useCallback(() => {
+ if (!firstItemRef.current) return;
+ const size = firstItemRef.current.offsetHeight;
+ if (size > 0) setItemSize(size);
+ }, []);
+
+ useEffect(() => {
+ measure();
+ }, [measure, childArray]);
+
+ // Start/stop the cycling timer
+ const startTimer = useCallback(() => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ if (count <= 1) return;
+ timerRef.current = setInterval(() => {
+ if (!isPausedRef.current) {
+ setTransitioning(true);
+ setIndex((prev) => (isReverse ? prev - 1 : prev + 1));
+ }
+ }, interval);
+ }, [count, interval, isReverse]);
+
+ const stopTimer = useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ startTimer();
+ return stopTimer;
+ }, [startTimer, stopTimer]);
+
+ // Reset index when children or direction change
+ useEffect(() => {
+ setIndex(getInitialIndex());
+ setTransitioning(false);
+ }, [getInitialIndex]);
+
+ // Handle seamless loop reset and finite mode stop
+ const handleTransitionEnd = useCallback(
+ (e: React.TransitionEvent) => {
+ if (e.target !== e.currentTarget) return;
+
+ if (infinite) {
+ if (!isReverse && index >= count) {
+ // Forward: reached appended duplicate → reset to 0
+ setTransitioning(false);
+ setIndex(0);
+ } else if (isReverse && index <= 0) {
+ // Reverse: reached prepended duplicate → reset to count
+ setTransitioning(false);
+ setIndex(count);
+ }
+ } else {
+ if ((!isReverse && index >= count - 1) || (isReverse && index <= 0)) {
+ stopTimer();
+ }
+ }
+ },
+ [infinite, isReverse, index, count, stopTimer]
+ );
+
+ // Pause on hover
+ const handleMouseEnter = useCallback(() => {
+ if (pauseOnHover) isPausedRef.current = true;
+ }, [pauseOnHover]);
+
+ const handleMouseLeave = useCallback(() => {
+ if (pauseOnHover) isPausedRef.current = false;
+ }, [pauseOnHover]);
+
+ const offset = itemSize > 0 ? `${-(index * itemSize)}px` : `${-(index * 100)}%`;
+ const transform = `translateY(${offset})`;
+
+ // Check prefers-reduced-motion via matchMedia
+ const prefersReducedMotion =
+ typeof window !== 'undefined' &&
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+
+ const cls = classNames(prefixCls, className);
+
+ const containerStyle: React.CSSProperties = {
+ ...style,
+ ...(itemSize > 0 ? { height: itemSize } : {}),
+ };
+
+ const trackStyle: React.CSSProperties = {
+ transform,
+ transition: transitioning && !prefersReducedMotion ? 'transform 300ms ease-in-out' : 'none',
+ };
+
+ return (
+
+
+ {items.map((child, i) => (
+
+ {child}
+
+ ))}
+
+
+ );
+});
+
+TextLoop.displayName = 'TextLoop';
+
+export default TextLoop;
diff --git a/packages/react/src/text-loop/types.ts b/packages/react/src/text-loop/types.ts
new file mode 100644
index 00000000..dc2c6457
--- /dev/null
+++ b/packages/react/src/text-loop/types.ts
@@ -0,0 +1,15 @@
+import React from 'react';
+import { BaseProps } from '../_utils/props';
+
+export interface TextLoopProps
+ extends BaseProps,
+ React.PropsWithoutRef {
+ /** Time each item stays visible, in ms (default: 3000) */
+ interval?: number;
+ /** Pause cycling on hover (default: true) */
+ pauseOnHover?: boolean;
+ /** Loop infinitely or stop after one full cycle (default: true) */
+ infinite?: boolean;
+ /** Cycling direction (default: 'up') */
+ direction?: 'up' | 'down';
+}