Marquee
A component that displays a continuous stream of content.
Foundations
'use client';
import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';
const MarqueePreview = () => {
return (
<Marquee className="w-96 items-center gap-2" direction="left">
Foundations
<Egg />
</Marquee>
);
};
export default MarqueePreview; Dependencies
Source Code
'use client';
import {
Children,
type ComponentPropsWithoutRef,
cloneElement,
type ReactElement,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useIntersectionObserver } from '@/foundations/hooks/use-intersection-observer';
import { useTicker } from '@/foundations/hooks/use-ticker';
import { cn } from '@/lib/utils/classnames';
type DurationProp = number | ((contentLength: number) => number);
interface MarqueeProps extends ComponentPropsWithoutRef<'div'> {
direction?: 'left' | 'right' | 'up' | 'down';
paused?: boolean;
duration?: DurationProp;
}
export const Marquee = ({
direction: propDirection = 'left',
paused = false,
children,
duration,
style,
className,
...props
}: MarqueeProps) => {
const [numClones, setNumClones] = useState<number>(1);
const [rootRef, { isIntersecting }] =
useIntersectionObserver<HTMLDivElement>();
const progress = useRef(0);
const contentLength = useRef(0);
const deferredResizeHandler = useRef<() => void>(null);
const getDuration = useMemo(() => {
if (typeof duration === 'number') return () => duration;
if (typeof duration === 'function')
return () => duration(contentLength.current);
// default duration to 50ms per pixel
return () => contentLength.current * 50;
}, [duration]);
const [axis, direction] = useMemo(() => {
return [
propDirection === 'up' || propDirection === 'down' ? 'y' : 'x',
propDirection === 'up' || propDirection === 'left' ? 'normal' : 'reverse',
];
}, [propDirection]);
const ticker = useTicker((_timestamp, delta) => {
if (deferredResizeHandler.current) {
deferredResizeHandler.current();
deferredResizeHandler.current = null;
}
const root = rootRef.current;
if (!root) return;
progress.current = (progress.current + delta / getDuration()) % 1 || 0;
root.style.setProperty('--progress', progress.current.toString());
});
useEffect(() => {
if (paused || !isIntersecting) {
ticker.stop();
} else if (isIntersecting) {
ticker.start();
}
}, [ticker, paused, isIntersecting]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const content = [...root.children].filter(
(child) => !child.hasAttribute('data-clone')
);
const getLength = (element: HTMLElement) => {
return element.getBoundingClientRect()[axis === 'x' ? 'width' : 'height'];
};
const onResize = () => {
const rootLength = getLength(root);
const gap = Number(getComputedStyle(root).gap.replace('px', ''));
const gapLength = Number.isNaN(gap) ? 0 : gap;
contentLength.current = content.reduce(
(acc, item) => acc + getLength(item as HTMLElement),
0
);
const numClones = Math.ceil(rootLength / contentLength.current);
setNumClones(numClones);
root.style.setProperty(
'--content-length',
`${contentLength.current + gapLength * content.length}px`
);
};
const resizeObserver = new ResizeObserver(() => {
// if ticker is running, defer the resize handler to the next tick
// otherwise, call the handler immediately
if (ticker.paused) {
onResize();
} else {
deferredResizeHandler.current = () => onResize();
}
});
onResize();
content.forEach((item) => {
resizeObserver.observe(item);
});
return () => {
resizeObserver.disconnect();
deferredResizeHandler.current = null;
};
}, [axis, rootRef, ticker]);
const transformedChildren = useMemo(() => {
return Children.map(children, (child) => {
if (typeof child === 'string' || typeof child === 'number') {
return <span className="inline-block">{child}</span>;
}
return child;
});
}, [children]);
return (
<div
ref={rootRef}
role="marquee"
aria-live="off"
aria-atomic="false"
{...props}
className={cn(
'box-content flex w-max overflow-hidden will-change-transform',
'*:shrink-0 *:will-change-transform',
axis === 'x' && 'flex-row *:translate-x-(--translate)',
axis === 'y' && 'flex-col *:translate-y-(--translate)',
className
)}
style={{
...style,
'--translate': `calc((${direction === 'normal' ? '-1 * ' : '-1 + '}var(--progress,0)) * var(--content-length,0px))`,
}}
>
{transformedChildren}
{Array.from({ length: numClones }).map((_, index) =>
Children.map(transformedChildren, (child) =>
// biome-ignore lint/suspicious/noExplicitAny: expected
cloneElement(child as ReactElement<any>, {
key: index,
'aria-hidden': 'true',
'data-clone': '',
})
)
)}
</div>
);
}; Features
- Native-Like Implementation: Closely mirrors the behavior of the native (now deprecated) marquee element, including maintaining a single-element structure
- Dynamic Content Cloning: Automatically creates the optimal number of content clones based on element size and content length
- Automatic Text Wrapping: Text content is automatically wrapped in
<span>elements to ensure proper rendering with CSS transforms - Customizable Gap: Supports flexible spacing between items using CSS gap property, allowing for consistent and adjustable spacing between content elements
- Intersection Observer: Pauses animation when not in viewport
- Flexible Duration Control: Duration can be specified as:
- A fixed number
- A function that receives content length for fine-grained and responsive speed control
Anatomy
<Marquee>{/* Content */}</Marquee>
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
direction | "left" | 'left' | 'right' | 'up' | 'down' | The direction of the marquee. |
duration | (contentLength) => contentLength * 50 | number | ((contentLength: number) => number) | The duration of the marquee in milliseconds. |
paused | | boolean | Whether the marquee is paused. |
Examples
Direction
left
Foundations
up
Foundations
right
Foundations
down
Foundations
'use client';
import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';
const MarqueeDirectionExample = () => {
return (
<div className="grid grid-cols-2 gap-8 whitespace-pre">
{['left', 'up', 'right', 'down'].map((direction) => (
<div key={direction}>
<div className="mb-2 font-medium capitalize">{direction}</div>
<Marquee
direction={direction as 'left' | 'right' | 'up' | 'down'}
className="h-[2.5em] w-48 items-center gap-2 rounded border border-border px-2 text-foreground-secondary"
>
Foundations
<Egg />
</Marquee>
</div>
))}
</div>
);
};
export default MarqueeDirectionExample; Duration Control
1000
Foundations
'use client';
import { useState } from 'react';
import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';
const MarqueeDurationExample = () => {
const [duration, setDuration] = useState(1000);
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<label
htmlFor="speed"
className="whitespace-nowrap font-medium text-base"
>
Duration <span className="text-foreground-secondary">(ms)</span>
</label>
<input
id="speed"
type="range"
min={100}
max={2000}
step={100}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="w-48"
/>
<span className="w-12 text-foreground-secondary">{duration}</span>
</div>
<Marquee
duration={duration}
className="h-[2.5em] w-96 items-center gap-2 rounded border border-border px-2 text-foreground-secondary"
>
Foundations
<Egg />
</Marquee>
</div>
);
};
export default MarqueeDurationExample; Pause on Hover
Hover to pause
Foundations
'use client';
import { useState } from 'react';
import { Egg } from '@/components/icons/egg';
import { Marquee } from '@/components/marquee';
const MarqueePauseHoverExample = () => {
const [isPaused, setIsPaused] = useState(false);
return (
<div className="space-y-4">
<div className="font-medium text-base">Hover to pause</div>
<Marquee
paused={isPaused}
className="h-[2.5em] w-96 items-center gap-2 rounded border border-border px-2 text-foreground-secondary"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
Foundations
<Egg />
</Marquee>
</div>
);
};
export default MarqueePauseHoverExample; Dynamic Content
0
'use client';
import { MinusIcon, PlusIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Marquee } from '@/components/marquee';
import { Button } from '@/components/button';
const MarqueeDynamicContentExample = () => {
const [items, setItems] = useState(['0']);
const addItem = () => {
setItems((prevItems) => [...prevItems, `${prevItems.length}`]);
};
const removeItem = () => {
if (items.length > 1) {
setItems(items.slice(0, -1));
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Button onClick={addItem} variant="outline" size="sm">
<PlusIcon size={16} />
Add
</Button>
<Button onClick={removeItem} variant="outline" size="sm">
<MinusIcon size={16} />
Remove
</Button>
</div>
<Marquee className="h-[2.5em] w-96 items-center gap-2 rounded border border-border px-2 text-foreground-secondary">
{items}
</Marquee>
</div>
);
};
export default MarqueeDynamicContentExample; Best Practices
Avoid Media Without Dimensions: when using images or videos in a marquee, always specify explicit width and height attributes. Without proper dimensions, the content may cause layout shifts as it loads, disrupting the smooth scrolling behavior. This is especially important since the marquee needs to calculate the total content length to determine scrolling speed and clone count.
Previous
Instance Counter
Next
Sequence