Build a Smooth, Accessible React Marquee (Scrolling Text) Component
Build an accessible, performant React marquee with CSS transforms, dynamic speed, pause on hover, gradients, and reduced-motion support. Full code inside.
Image used for representation purposes only.
Overview
Marquee text (a continuously scrolling strip of content) is a classic UI pattern for headlines, stock tickers, or sponsor logos. The deprecated HTML
- Smooth and GPU‑accelerated using CSS transforms
- Fully controllable (speed, direction, pause on hover/focus)
- Respectful of accessibility and reduced‑motion preferences
- Adaptable to horizontal or vertical scrolling
- Seamless, with no visual jumps between loops
You’ll also see a minimal CSS‑only version, then a robust TypeScript component ready to drop into your app.
Design goals and UX checklist
Before touching code, clarify the behavior:
- Performance: Animate with transform/translate3d, not left/top. Set will-change: transform.
- Seamlessness: Duplicate the content so the loop never “jumps.”
- Readability: Use a speed in pixels/second; auto‑derive the animation duration from content size.
- Controls: Pause on hover and keyboard focus; allow programmatic pause/play.
- Reduced motion: Respect prefers-reduced-motion and provide a static fallback.
- Accessibility: Avoid repeating content for screen readers (mark duplicates aria-hidden). Keep focusability intact for interactive children.
- Responsiveness: Recompute sizes on resize/content changes with ResizeObserver.
How marquee looping works
The trick for an infinite loop is simple:
- Render your content once (the “unit”).
- Render N duplicates of that same unit to the right (or below) so that the visible viewport is always filled.
- Animate a track container by exactly the width (or height) of the unit. When it finishes, the next duplicate is perfectly aligned; the cycle restarts with no visible jump.
The minimal CSS-only version
If you only need a quick horizontal effect, here’s the bare bones. This does not auto‑size or handle accessibility as well as the React version, but it shows the technique.
<div class="marquee">
<div class="marquee__track">
<div class="marquee__unit">Breaking: New release ships today · Breaking: New release ships today ·</div>
<div class="marquee__unit" aria-hidden="true">Breaking: New release ships today · Breaking: New release ships today ·</div>
</div>
</div>
.marquee { position: relative; overflow: hidden; }
.marquee__track {
display: flex; gap: 0; white-space: nowrap;
will-change: transform;
animation: marquee 20s linear infinite; /* Fixed duration */
}
.marquee:hover .marquee__track, .marquee:focus-within .marquee__track {
animation-play-state: paused;
}
@keyframes marquee {
to { transform: translate3d(-50%, 0, 0); }
}
This uses a fixed 20s duration and two copies of the unit. The React component below improves on this by measuring the content, deriving a proper duration from a speed (px/s), supporting directions, reduced motion, and dynamic duplication to cover any viewport.
A production-ready React marquee (TypeScript)
Features:
- Horizontal or vertical
- Speed in px/second with automatic duration
- Direction: left/right/up/down
- Pause on hover/focus and programmatic play/pause
- Gradient fade edges
- Reduced motion support
- Seamless looping with the right number of duplicates
import React, { useEffect, useMemo, useRef, useState } from 'react';
export type MarqueeDirection = 'left' | 'right' | 'up' | 'down';
export interface MarqueeProps {
children: React.ReactNode;
speed?: number; // pixels per second, default 60
direction?: MarqueeDirection; // default 'left'
pauseOnHover?: boolean; // default true
pauseOnFocus?: boolean; // default true
play?: boolean; // programmatic control, default true
gradient?: boolean; // fade edges, default false
gradientColor?: string; // rgba/hex, default uses current background
gradientWidth?: number | string; // px or rem, default 40
className?: string;
trackClassName?: string;
ariaLabel?: string; // optional label for assistive tech
}
export const Marquee: React.FC<MarqueeProps> = ({
children,
speed = 60,
direction = 'left',
pauseOnHover = true,
pauseOnFocus = true,
play = true,
gradient = false,
gradientColor = 'var(--marquee-gradient, #fff)',
gradientWidth = 40,
className,
trackClassName,
ariaLabel,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const unitRef = useRef<HTMLDivElement>(null);
const [unitSize, setUnitSize] = useState({ width: 0, height: 0 });
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const isHorizontal = direction === 'left' || direction === 'right';
const sign = direction === 'left' || direction === 'up' ? -1 : 1;
// Derive repeat count so content always covers viewport + one extra copy
const repeat = useMemo(() => {
const c = containerSize[isHorizontal ? 'width' : 'height'];
const u = unitSize[isHorizontal ? 'width' : 'height'];
if (!c || !u) return 2; // safe default
return Math.max(2, Math.ceil(c / u) + 1);
}, [containerSize, unitSize, isHorizontal]);
// Distance of a single cycle equals unit length
const distance = unitSize[isHorizontal ? 'width' : 'height'] || 0;
// Duration (s) = pixels / (px per second)
const duration = useMemo(() => {
if (!distance || !speed) return 0;
return distance / Math.max(1, speed);
}, [distance, speed]);
// Resize observers to recompute sizes on content or container changes
useEffect(() => {
const roUnit = new ResizeObserver(entries => {
const cr = entries[0]?.contentRect;
if (cr) setUnitSize({ width: cr.width, height: cr.height });
});
const roContainer = new ResizeObserver(entries => {
const cr = entries[0]?.contentRect;
if (cr) setContainerSize({ width: cr.width, height: cr.height });
});
if (unitRef.current) roUnit.observe(unitRef.current);
if (containerRef.current) roContainer.observe(containerRef.current);
// If fonts are loading, recompute after they’re ready for accurate metrics
if (document?.fonts && 'ready' in document.fonts) {
document.fonts.ready.then(() => {
if (unitRef.current) {
const rect = unitRef.current.getBoundingClientRect();
setUnitSize({ width: rect.width, height: rect.height });
}
});
}
return () => { roUnit.disconnect(); roContainer.disconnect(); };
}, []);
const gradientStyle: React.CSSProperties | undefined = gradient
? {
['--marquee-gradient-color' as any]: gradientColor,
['--marquee-gradient-size' as any]: typeof gradientWidth === 'number' ? `${gradientWidth}px` : gradientWidth,
}
: undefined;
const vars: React.CSSProperties = {
['--marquee-duration' as any]: duration ? `${duration}s` : undefined,
['--marquee-x' as any]: isHorizontal ? `${sign * (distance || 0)}px` : '0px',
['--marquee-y' as any]: !isHorizontal ? `${sign * (distance || 0)}px` : '0px',
animationPlayState: play ? 'running' : 'paused',
};
const pauseOnInteraction = (pauseOnHover || pauseOnFocus) ? {
['data-pause-on-hover']: pauseOnHover ? true : undefined,
['data-pause-on-focus']: pauseOnFocus ? true : undefined,
} : {};
return (
<div
ref={containerRef}
className={[
'rmq-container',
isHorizontal ? 'rmq-horizontal' : 'rmq-vertical',
className || ''
].join(' ')}
style={gradientStyle}
aria-label={ariaLabel}
{...pauseOnInteraction}
>
{gradient && (
<>
<div className="rmq-fade rmq-fade--start" aria-hidden="true" />
<div className="rmq-fade rmq-fade--end" aria-hidden="true" />
</>
)}
{/* The track that moves */}
<div
className={[ 'rmq-track', trackClassName || '' ].join(' ')}
style={vars}
role="presentation"
>
{/* One accessible unit */}
<div ref={unitRef} className="rmq-unit">
{children}
</div>
{/* Duplicate units for seamless loop; hidden from AT */}
{Array.from({ length: Math.max(1, repeat - 1) }).map((_, i) => (
<div key={i} className="rmq-unit" aria-hidden="true">
{children}
</div>
))}
</div>
</div>
);
};
Add the accompanying CSS. You can colocate it, use CSS Modules, or a global stylesheet.
/* Container */
.rmq-container { position: relative; overflow: hidden; }
.rmq-horizontal .rmq-track { display: inline-flex; white-space: nowrap; }
.rmq-vertical .rmq-track { display: inline-flex; flex-direction: column; }
/* The moving track */
.rmq-track {
will-change: transform;
animation: rmq-marquee var(--marquee-duration, 0s) linear infinite;
}
/* Pause on interaction (opt-in via data attributes) */
.rmq-container[data-pause-on-hover="true"]:hover .rmq-track { animation-play-state: paused; }
.rmq-container[data-pause-on-focus="true"]:focus-within .rmq-track { animation-play-state: paused; }
/* Reduced motion: stop animation */
@media (prefers-reduced-motion: reduce) {
.rmq-track { animation: none !important; transform: none !important; }
}
/* The content unit */
.rmq-unit { display: inline-flex; align-items: center; }
/* Keyframes use CSS variables to support all directions */
@keyframes rmq-marquee {
to { transform: translate3d(var(--marquee-x, 0), var(--marquee-y, 0), 0); }
}
/* Optional gradient fades (overlay elements) */
.rmq-fade {
position: absolute; top: 0; bottom: 0; width: var(--marquee-gradient-size, 40px);
pointer-events: none; z-index: 2;
}
.rmq-horizontal .rmq-fade--start { left: 0; background: linear-gradient(to right, var(--marquee-gradient-color, #fff), transparent); }
.rmq-horizontal .rmq-fade--end { right: 0; background: linear-gradient(to left, var(--marquee-gradient-color, #fff), transparent); }
.rmq-vertical .rmq-fade--start { top: 0; height: var(--marquee-gradient-size, 40px); width: 100%; background: linear-gradient(to bottom, var(--marquee-gradient-color, #fff), transparent); }
.rmq-vertical .rmq-fade--end { bottom: 0; height: var(--marquee-gradient-size, 40px); width: 100%; background: linear-gradient(to top, var(--marquee-gradient-color, #fff), transparent); }
Usage examples
Basic horizontal ticker:
<Marquee speed={80} gradient gradientColor="#0b1020" gradientWidth={60}>
<span style={{ paddingRight: 32 }}>Breaking: New design system launched</span>
<span style={{ paddingRight: 32 }}>Docs: v3 shipped with MDX</span>
<span style={{ paddingRight: 32 }}>Conference: CFP closes Friday</span>
</Marquee>
Right-to-left (Arabic/Hebrew UI) or reverse scroll:
<Marquee direction="right" speed={50}>
<span style={{ paddingInline: 24 }}>تحديث: إصدار جديد اليوم</span>
<span style={{ paddingInline: 24 }}>العروض مستمرة حتى الأحد</span>
</Marquee>
Vertical sponsor wall:
<Marquee direction="up" speed={40} gradient gradientColor="#0a0a0a">
{["Acme", "Globex", "Umbrella", "Initech", "Wonka"].map((name) => (
<img key={name} src={`/logos/${name}.svg`} alt={`${name} logo`} style={{ height: 32, margin: '12px 24px' }} />
))}
</Marquee>
Programmatic play/pause (e.g., toggle with a button):
function Ticker() {
const [play, setPlay] = React.useState(true);
return (
<>
<button onClick={() => setPlay(p => !p)}>{play ? 'Pause' : 'Play'}</button>
<Marquee play={play} pauseOnHover pauseOnFocus speed={70}>
<strong style={{ marginRight: 24 }}>Live:</strong> Team A 3–1 Team B · Finals Game 2 · Stream now
</Marquee>
</>
);
}
Accessibility notes
- Duplicates are aria-hidden so screen readers don’t announce repeating content.
- Interactive children (links, buttons) remain focusable and will pause the track on focus if pauseOnFocus is true.
- For very long, critical announcements, consider also rendering a non‑animated fallback (e.g., inside a visually hidden region for SR users) or using aria-live=“polite” on a static container if messages change over time.
- Respect reduced motion. Users with motion sensitivity should see content without continuous movement.
Performance tips
- Keep the number of duplicates minimal: we compute repeat based on container/unit size to cover the viewport plus one extra copy.
- Use transform: translate3d(…) to ensure GPU compositing; avoid animating left/top.
- Avoid overly large shadows, filters, or nested 3D transforms inside the moving unit.
- If the marquee lives offscreen (e.g., in a hidden tab), consider pausing it using Page Visibility API or an IntersectionObserver to save CPU.
Common pitfalls and fixes
- Content measures to 0: Ensure the unit has actual rendered children; wait for fonts to load (document.fonts.ready) if custom web fonts are used.
- Gaps between loops: Ensure the distance equals the size of a single unit, and that unit content has no unexpected margins. Prefer padding on child elements instead of margins on the outer unit.
- Janky motion on low-end devices: Lower the speed (fewer pixels per second), reduce the font size/graphics weight, or disable gradients if they cause repaints.
- Direction looks reversed: Remember that left/up use a negative sign; right/down use positive.
Extending the component
- Variable speed: Accept an array of segments and switch CSS variables mid‑animation by keyframe percentages.
- Controlled step/clip mode: Replace the linear keyframe with steps(n, end) to create a snapping ticker.
- Content virtualization: For extremely heavy child lists, render only visible items plus a buffer and reposition on wrap—though this complicates seamless loops.
Final thoughts
A marquee can add motion and density to a UI, but it’s easy to overdo. Keep speeds readable (40–100 px/s), provide interaction pauses, and always honor reduced motion. With the React Marquee component above, you’ll get smooth, accessible, and configurable scrolling text or logos that work across layouts and languages.
Related Posts
Building an Accessible, Animated React Accordion (Headless, Tested, and Themeable)
Build an accessible, animated React accordion: ARIA semantics, keyboard support, headless API, smooth height transitions, tests, and performance tips.
Building an Accessible, Reusable React Progress Indicator (Linear and Circular)
Build an accessible, themeable React progress indicator (linear and circular) with TypeScript, complete code, a11y, performance tips, theming, and tests.
Build a Robust React Clipboard Copy/Paste Component (Hooks, A11y, Fallbacks)
Build a production-ready React clipboard copy/paste component with hooks, fallbacks, accessibility, sanitization, and tests.