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.

ASOasis
9 min read
Build a Smooth, Accessible React Marquee (Scrolling Text) Component

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 tag once did the job, but it’s inaccessible, unpredictable, and not supported in modern standards. In this article, you’ll build a production‑ready React marquee component that is:

  • 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:

  1. Render your content once (the “unit”).
  2. Render N duplicates of that same unit to the right (or below) so that the visible viewport is always filled.
  3. 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 31 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