diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx
index d6882cba..8720635a 100755
--- a/apps/docs/src/routers.tsx
+++ b/apps/docs/src/routers.tsx
@@ -84,6 +84,7 @@ const c = {
descriptions: ll(() => import('../../../packages/react/src/descriptions/index.md'), () => import('../../../packages/react/src/descriptions/index.zh_CN.md')),
flip: ll(() => import('../../../packages/react/src/flip/index.md'), () => import('../../../packages/react/src/flip/index.zh_CN.md')),
list: ll(() => import('../../../packages/react/src/list/index.md'), () => import('../../../packages/react/src/list/index.zh_CN.md')),
+ marquee: ll(() => import('../../../packages/react/src/marquee/index.md'), () => import('../../../packages/react/src/marquee/index.zh_CN.md')),
popover: ll(() => import('../../../packages/react/src/popover/index.md'), () => import('../../../packages/react/src/popover/index.zh_CN.md')),
progress: ll(() => import('../../../packages/react/src/progress/index.md'), () => import('../../../packages/react/src/progress/index.zh_CN.md')),
statistic: ll(() => import('../../../packages/react/src/statistic/index.md'), () => import('../../../packages/react/src/statistic/index.zh_CN.md')),
@@ -210,6 +211,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
{ title: 'Descriptions', route: 'descriptions', component: pick(c.descriptions, z) },
{ title: 'Flip', route: 'flip', component: pick(c.flip, z) },
{ title: 'List', route: 'list', component: pick(c.list, z) },
+ { 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: 'Statistic', route: 'statistic', component: pick(c.statistic, z) },
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 41a762f3..f0b6329c 100755
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -39,6 +39,7 @@ export { default as Link } from './link';
export { default as List } from './list';
export { default as Loader } from './loader';
export { default as LoadingBar } from './loading-bar';
+export { default as Marquee } from './marquee';
export { default as Menu } from './menu';
export { default as Message } from './message';
export { default as NativeSelect } from './native-select';
diff --git a/packages/react/src/marquee/__tests__/__snapshots__/marquee.test.tsx.snap b/packages/react/src/marquee/__tests__/__snapshots__/marquee.test.tsx.snap
new file mode 100644
index 00000000..05ffd5e3
--- /dev/null
+++ b/packages/react/src/marquee/__tests__/__snapshots__/marquee.test.tsx.snap
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
should match the snapshot 1`] = `
+
+
+
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3
+
+
+
+
+`;
diff --git a/packages/react/src/marquee/__tests__/marquee.test.tsx b/packages/react/src/marquee/__tests__/marquee.test.tsx
new file mode 100644
index 00000000..e250ff77
--- /dev/null
+++ b/packages/react/src/marquee/__tests__/marquee.test.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import Marquee from '../index';
+
+describe('
', () => {
+ it('should match the snapshot', () => {
+ const { asFragment } = render(
+
+ );
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it('should render children duplicated for seamless loop', () => {
+ const { getAllByText } = render(
+
+ );
+ expect(getAllByText('Item')).toHaveLength(2);
+ });
+
+ it('should apply reverse class to track when direction is right', () => {
+ const { container } = render(
+
+ );
+ const track = container.querySelector('.ty-marquee__track');
+ expect(track).toHaveClass('ty-marquee__track_reverse');
+ });
+
+ it('should apply pause-on-hover class to track by default', () => {
+ const { container } = render(
+
+ );
+ const track = container.querySelector('.ty-marquee__track');
+ expect(track).toHaveClass('ty-marquee__track_pause-on-hover');
+ });
+
+ it('should not apply pause-on-hover class when disabled', () => {
+ const { container } = render(
+
+ );
+ const track = container.querySelector('.ty-marquee__track');
+ expect(track).not.toHaveClass('ty-marquee__track_pause-on-hover');
+ });
+
+ it('should apply fade class to wrapper when fade is true', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('ty-marquee_fade');
+ });
+
+ it('should have overflow hidden on wrapper', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('ty-marquee');
+ });
+
+ it('should set duration and gap as CSS variables on track', () => {
+ const { container } = render(
+
+ );
+ const track = container.querySelector('.ty-marquee__track') as HTMLElement;
+ expect(track.style.getPropertyValue('--ty-marquee-duration')).toBe('30s');
+ expect(track.style.getPropertyValue('--ty-marquee-gap')).toBe('24px');
+ });
+
+ it('should forward ref to wrapper', () => {
+ const ref = React.createRef
();
+ render(
+
+ );
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ expect(ref.current).toHaveClass('ty-marquee');
+ });
+
+ it('should pass through custom className', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('ty-marquee');
+ expect(container.firstChild).toHaveClass('custom');
+ });
+
+ it('should not duplicate children when infinite is false', () => {
+ const { getAllByText } = render(
+
+ );
+ expect(getAllByText('Item')).toHaveLength(1);
+ });
+
+ it('should apply once class to track when infinite is false', () => {
+ const { container } = render(
+
+ );
+ const track = container.querySelector('.ty-marquee__track');
+ expect(track).toHaveClass('ty-marquee__track_once');
+ });
+
+ it('should not apply once class by default', () => {
+ const { container } = render(
+
+ );
+ const track = container.querySelector('.ty-marquee__track');
+ expect(track).not.toHaveClass('ty-marquee__track_once');
+ });
+});
diff --git a/packages/react/src/marquee/demo/basic.tsx b/packages/react/src/marquee/demo/basic.tsx
new file mode 100644
index 00000000..f7cc1631
--- /dev/null
+++ b/packages/react/src/marquee/demo/basic.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Marquee } from '@tiny-design/react';
+
+const itemStyle: React.CSSProperties = {
+ flexShrink: 0,
+ padding: '12px 24px',
+ borderRadius: 8,
+ background: 'var(--ty-color-bg-component)',
+ border: '1px solid var(--ty-color-border-secondary)',
+ whiteSpace: 'nowrap',
+};
+
+export default function BasicDemo() {
+ return (
+
+ );
+}
diff --git a/packages/react/src/marquee/demo/cards.tsx b/packages/react/src/marquee/demo/cards.tsx
new file mode 100644
index 00000000..6445b02d
--- /dev/null
+++ b/packages/react/src/marquee/demo/cards.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Marquee } from '@tiny-design/react';
+
+const cardStyle: React.CSSProperties = {
+ flexShrink: 0,
+ width: 200,
+ padding: 16,
+ borderRadius: 12,
+ background: 'var(--ty-color-bg-component)',
+ border: '1px solid var(--ty-color-border-secondary)',
+};
+
+const avatarStyle: React.CSSProperties = {
+ width: 40,
+ height: 40,
+ borderRadius: '50%',
+ background: 'var(--ty-color-primary)',
+ opacity: 0.2,
+};
+
+const nameStyle: React.CSSProperties = {
+ height: 12,
+ width: '60%',
+ borderRadius: 6,
+ background: 'var(--ty-color-text-secondary)',
+ opacity: 0.2,
+ marginTop: 12,
+};
+
+const textStyle: React.CSSProperties = {
+ height: 8,
+ borderRadius: 4,
+ background: 'var(--ty-color-text-secondary)',
+ opacity: 0.1,
+ marginTop: 8,
+};
+
+export default function CardsDemo() {
+ return (
+
+ );
+}
diff --git a/packages/react/src/marquee/demo/direction.tsx b/packages/react/src/marquee/demo/direction.tsx
new file mode 100644
index 00000000..f458892d
--- /dev/null
+++ b/packages/react/src/marquee/demo/direction.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { Marquee } from '@tiny-design/react';
+
+const itemStyle: React.CSSProperties = {
+ flexShrink: 0,
+ padding: '12px 24px',
+ borderRadius: 8,
+ background: 'var(--ty-color-bg-component)',
+ border: '1px solid var(--ty-color-border-secondary)',
+ whiteSpace: 'nowrap',
+};
+
+export default function DirectionDemo() {
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/react/src/marquee/demo/speed.tsx b/packages/react/src/marquee/demo/speed.tsx
new file mode 100644
index 00000000..2ec53baf
--- /dev/null
+++ b/packages/react/src/marquee/demo/speed.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { Marquee } from '@tiny-design/react';
+
+const itemStyle: React.CSSProperties = {
+ flexShrink: 0,
+ padding: '12px 24px',
+ borderRadius: 8,
+ background: 'var(--ty-color-bg-component)',
+ border: '1px solid var(--ty-color-border-secondary)',
+ whiteSpace: 'nowrap',
+};
+
+export default function SpeedDemo() {
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/react/src/marquee/index.md b/packages/react/src/marquee/index.md
new file mode 100644
index 00000000..acecbd40
--- /dev/null
+++ b/packages/react/src/marquee/index.md
@@ -0,0 +1,78 @@
+import BasicDemo from './demo/basic';
+import BasicSource from './demo/basic.tsx?raw';
+import DirectionDemo from './demo/direction';
+import DirectionSource from './demo/direction.tsx?raw';
+import SpeedDemo from './demo/speed';
+import SpeedSource from './demo/speed.tsx?raw';
+import CardsDemo from './demo/cards';
+import CardsSource from './demo/cards.tsx?raw';
+
+# Marquee
+
+An infinite scrolling marquee component that automatically loops content horizontally.
+
+## Scenario
+
+Use when you want to display a continuous stream of items (e.g. logos, cards, tags) that scroll automatically and pause on hover.
+
+## Usage
+
+```jsx
+import { Marquee } from 'tiny-design';
+```
+
+## Examples
+
+
+
+
+
+### Basic
+
+A basic marquee with edge fade effect. Hover to pause.
+
+
+
+
+
+
+### Speed
+
+Control the scroll speed with `duration`. A smaller value scrolls faster.
+
+
+
+
+
+
+
+
+### Direction
+
+Use `direction="right"` to reverse the scroll direction. Combine two rows for a staggered effect.
+
+
+
+
+
+
+### Cards
+
+Marquee works with any content, such as cards with rich layouts.
+
+
+
+
+
+
+
+## API
+
+| Property | Description | Type | Default |
+| ----------- | ---------------------------------- | -------------------------------- | ------- |
+| direction | Scroll direction | enum: `left` | `right` | `left` |
+| duration | Animation duration in seconds | number | 50 |
+| pauseOnHover| Pause animation on hover | boolean | true |
+| gap | Gap between items in pixels | number | 16 |
+| fade | Apply edge fade mask | boolean | false |
+| infinite | Loop animation infinitely | boolean | true |
diff --git a/packages/react/src/marquee/index.tsx b/packages/react/src/marquee/index.tsx
new file mode 100644
index 00000000..17289a9c
--- /dev/null
+++ b/packages/react/src/marquee/index.tsx
@@ -0,0 +1,3 @@
+import Marquee from './marquee';
+
+export default Marquee;
diff --git a/packages/react/src/marquee/index.zh_CN.md b/packages/react/src/marquee/index.zh_CN.md
new file mode 100644
index 00000000..5709a857
--- /dev/null
+++ b/packages/react/src/marquee/index.zh_CN.md
@@ -0,0 +1,78 @@
+import BasicDemo from './demo/basic';
+import BasicSource from './demo/basic.tsx?raw';
+import DirectionDemo from './demo/direction';
+import DirectionSource from './demo/direction.tsx?raw';
+import SpeedDemo from './demo/speed';
+import SpeedSource from './demo/speed.tsx?raw';
+import CardsDemo from './demo/cards';
+import CardsSource from './demo/cards.tsx?raw';
+
+# 跑马灯
+
+一个自动循环滚动内容的无限滚动组件。
+
+## 使用场景
+
+当你需要展示一组连续滚动的内容(如 Logo、卡片、标签)时使用,支持自动滚动和悬停暂停。
+
+## 引入方式
+
+```jsx
+import { Marquee } from 'tiny-design';
+```
+
+## 代码示例
+
+
+
+
+
+### 基础用法
+
+基础跑马灯,带边缘渐隐效果。悬停时暂停。
+
+
+
+
+
+
+### 速度
+
+通过 `duration` 控制滚动速度。值越小,滚动越快。
+
+
+
+
+
+
+
+
+### 方向
+
+使用 `direction="right"` 反转滚动方向。组合两行可实现交错效果。
+
+
+
+
+
+
+### 卡片
+
+跑马灯适用于任意内容,例如富布局卡片。
+
+
+
+
+
+
+
+## API
+
+| 属性 | 说明 | 类型 | 默认值 |
+| ------------ | --------------------- | -------------------------------- | ------- |
+| direction | 滚动方向 | enum: `left` | `right` | `left` |
+| duration | 动画持续时间(秒) | number | 50 |
+| pauseOnHover | 悬停时暂停动画 | boolean | true |
+| gap | 项目间距(像素) | number | 16 |
+| fade | 应用边缘渐隐遮罩 | boolean | false |
+| infinite | 无限循环动画 | boolean | true |
diff --git a/packages/react/src/marquee/marquee.tsx b/packages/react/src/marquee/marquee.tsx
new file mode 100644
index 00000000..302bb7be
--- /dev/null
+++ b/packages/react/src/marquee/marquee.tsx
@@ -0,0 +1,68 @@
+import React, { useContext, useMemo } from 'react';
+import classNames from 'classnames';
+import { ConfigContext } from '../config-provider/config-context';
+import { getPrefixCls } from '../_utils/general';
+import { MarqueeProps } from './types';
+
+const Marquee = React.memo(
+ React.forwardRef((props, ref) => {
+ const {
+ direction = 'left',
+ duration = 50,
+ pauseOnHover = true,
+ gap = 16,
+ fade = false,
+ infinite = true,
+ prefixCls: customisedCls,
+ className,
+ style,
+ children,
+ ...otherProps
+ } = props;
+
+ const configContext = useContext(ConfigContext);
+ const prefixCls = getPrefixCls('marquee', configContext.prefixCls, customisedCls);
+
+ const cls = classNames(
+ prefixCls,
+ {
+ [`${prefixCls}_fade`]: fade,
+ },
+ className
+ );
+
+ const trackCls = classNames(`${prefixCls}__track`, {
+ [`${prefixCls}__track_reverse`]: direction === 'right',
+ [`${prefixCls}__track_pause-on-hover`]: pauseOnHover,
+ [`${prefixCls}__track_once`]: !infinite,
+ });
+
+ const trackStyle: React.CSSProperties = {
+ '--ty-marquee-duration': `${duration}s`,
+ '--ty-marquee-gap': `${gap}px`,
+ } as React.CSSProperties;
+
+ const items = useMemo(() => {
+ const childArray = React.Children.toArray(children);
+ if (!infinite) return childArray;
+ const cloned = childArray.map((child) =>
+ React.isValidElement(child)
+ ? React.cloneElement(child, { key: `${child.key}-dup` })
+ : child
+ );
+ return [...childArray, ...cloned];
+ }, [children, infinite]);
+
+ return (
+
+ );
+ })
+);
+
+Marquee.displayName = 'Marquee';
+
+export default Marquee;
diff --git a/packages/react/src/marquee/style/_index.scss b/packages/react/src/marquee/style/_index.scss
new file mode 100644
index 00000000..31bd7b7f
--- /dev/null
+++ b/packages/react/src/marquee/style/_index.scss
@@ -0,0 +1,77 @@
+@use '../../style/variables' as *;
+
+.#{$prefix}-marquee {
+ overflow: hidden;
+ width: 0;
+ min-width: 100%;
+
+ &_fade {
+ mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
+ mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
+ }
+
+ &__track {
+ display: flex;
+ gap: var(--ty-marquee-gap, 16px);
+ width: max-content;
+ animation: ty-marquee-left var(--ty-marquee-duration, 50s) linear infinite;
+
+ &_reverse {
+ animation-name: ty-marquee-right;
+ }
+
+ &_pause-on-hover:hover {
+ animation-play-state: paused;
+ }
+
+ &_once {
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ animation-name: ty-marquee-once-left;
+
+ &.#{$prefix}-marquee__track_reverse {
+ animation-name: ty-marquee-once-right;
+ }
+ }
+ }
+}
+
+@keyframes ty-marquee-left {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(-50%);
+ }
+}
+
+@keyframes ty-marquee-right {
+ from {
+ transform: translateX(-50%);
+ }
+
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes ty-marquee-once-left {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(-100%);
+ }
+}
+
+@keyframes ty-marquee-once-right {
+ from {
+ transform: translateX(-100%);
+ }
+
+ to {
+ transform: translateX(0);
+ }
+}
diff --git a/packages/react/src/marquee/style/index.tsx b/packages/react/src/marquee/style/index.tsx
new file mode 100644
index 00000000..dca5d2a0
--- /dev/null
+++ b/packages/react/src/marquee/style/index.tsx
@@ -0,0 +1 @@
+import './_index.scss';
diff --git a/packages/react/src/marquee/types.ts b/packages/react/src/marquee/types.ts
new file mode 100644
index 00000000..fec42121
--- /dev/null
+++ b/packages/react/src/marquee/types.ts
@@ -0,0 +1,24 @@
+import React from 'react';
+import { BaseProps } from '../_utils/props';
+
+export interface MarqueeProps
+ extends BaseProps,
+ React.PropsWithoutRef {
+ /** Scroll direction */
+ direction?: 'left' | 'right';
+
+ /** Animation duration in seconds */
+ duration?: number;
+
+ /** Pause animation on hover */
+ pauseOnHover?: boolean;
+
+ /** Gap between items in pixels */
+ gap?: number;
+
+ /** Apply edge fade mask */
+ fade?: boolean;
+
+ /** Loop the animation infinitely (duplicates children for seamless loop) */
+ infinite?: boolean;
+}
diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss
index 78b8ad30..e602a183 100644
--- a/packages/react/src/style/_component.scss
+++ b/packages/react/src/style/_component.scss
@@ -36,6 +36,7 @@
@use "../loader/style/index" as *;
@use "../loading-bar/style/index" as *;
@use "../keyboard/style/index" as *;
+@use "../marquee/style/index" as *;
@use "../menu/style/index" as *;
@use "../message/style/index" as *;
@use "../modal/style/index" as *;