Skip to content
UI Kit
Accent color

Marquee for React

An endless row or column of cards, logos or previews, never of bare words. Stops on hover and focus, drifts with the scroll if you like, still and scrollable with reduced motion.

One of 6 components in Beams, particles and motion

3 variants

An endless row that stays calm

A marquee runs a row of logos, cards or previews endlessly, a way to show more than fits without a carousel to operate. This one moves cards and pictures, never bare words, pauses on hover and keyboard focus so every item can be read and clicked, and duplicates its items for screen readers only once.

Logo row is the classic band of customer logos below a hero. Two rows of cards runs testimonials or features in opposite directions. Vertical columns lets cards climb in two or three columns beside a headline, a calm alternative for a hero. Each variant can drift with the scroll instead of running on its own.

Keep the speed slow, people should be able to read a card as it passes. Use at least twice as many items as fit on the screen, so the loop is not obvious. With reduced motion the row stands still and scrolls by hand, check that it still looks complete.

Variants

Logo row

Customer logos run under the copy, the edges fade.

Two rows of cards

Dish and drink cards in two rows that run in opposite directions and slide past each other while you scroll. Hover stops a row and lifts the card.

Vertical columns

Reviews climb in two columns next to the copy, one of them reversed.

Details

What Marquee brings along, read from the registry and the source.

Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/marquee.tsx
Brings along
utilskit
npm packages
None
Languages
English and German, 1 text of its own
Studio
Adjust in code

Props

The MarqueeProps interface from components/ui/marquee.tsx, with the comments from the code.

  • childrenrequired
    React.ReactNode

    Cards, logos with a mark, images or previews. Never a row of bare words.

  • directionoptional
    'horizontal' | 'vertical'

    horizontal runs right to left, vertical bottom to top

  • reverseoptional
    boolean

    Run the other way

  • speedoptional
    number

    Pixels per second, default 40

  • gapoptional
    number

    Space between items in px, default 40 (horizontal) or 16 (vertical)

  • fadeoptional
    boolean

    Soft fade on both ends, default true

  • pauseOnHoveroptional
    boolean

    Stop on hover and keyboard focus, default true

  • scrollShiftoptional
    number

    Pixels the whole row drifts on top of its run while the page scrolls past, e.g. 120. Positive moves against the run, negative with it. Two rows with opposite signs slide past each other. Default 0, off with reduced motion.

  • labeloptional
    string

    Name of the region for screen readers, e.g. "Customer logos"

  • itemClassNameoptional
    string

    Classes for each item wrapper

Built-in texts

Marquee writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 1 texts
regionEnglish Scrolling listGerman Laufende Liste

Accessibility

Read from the source of the component.

  • Sets aria-label and aria-roledescription, so screen readers announce purpose and state.
  • Uses the roles clone and region.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 177 lines
components/ui/marquee.tsx
'use client';

import { Children, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';

const TEXTS = {
  en: { region: 'Scrolling list' },
  de: { region: 'Laufende Liste' },
};

export interface MarqueeProps {
  /** Cards, logos with a mark, images or previews. Never a row of bare words. */
  children: React.ReactNode;
  /** horizontal runs right to left, vertical bottom to top */
  direction?: 'horizontal' | 'vertical';
  /** Run the other way */
  reverse?: boolean;
  /** Pixels per second, default 40 */
  speed?: number;
  /** Space between items in px, default 40 (horizontal) or 16 (vertical) */
  gap?: number;
  /** Soft fade on both ends, default true */
  fade?: boolean;
  /** Stop on hover and keyboard focus, default true */
  pauseOnHover?: boolean;
  /**
   * Pixels the whole row drifts on top of its run while the page scrolls past,
   * e.g. 120. Positive moves against the run, negative with it. Two rows with
   * opposite signs slide past each other. Default 0, off with reduced motion.
   */
  scrollShift?: number;
  /** Name of the region for screen readers, e.g. "Customer logos" */
  label?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  /** Vertical marquees need a height, e.g. h-[420px] */
  className?: string;
  /** Classes for each item wrapper */
  itemClassName?: string;
}

const CSS = `
@keyframes kitfx-marquee-x { to { transform: translate3d(calc(-100% / var(--kitfx-n)), 0, 0); } }
@keyframes kitfx-marquee-y { to { transform: translate3d(0, calc(-100% / var(--kitfx-n)), 0); } }
.kitfx-mq-track { animation-play-state: var(--kitfx-play, running); }
.kitfx-mq[data-pause]:hover .kitfx-mq-track, .kitfx-mq[data-pause]:focus-within .kitfx-mq-track { animation-play-state: paused; }
.kitfx-mq-shift { transition: transform .3s linear; }
@media (prefers-reduced-motion: reduce) {
  .kitfx-mq { overflow: auto !important; overscroll-behavior: contain; }
  .kitfx-mq-track { animation: none !important; }
  .kitfx-mq-shift { transform: none !important; }
  .kitfx-mq-copy[data-copy="clone"] { display: none !important; }
}
`;

/**
 * An endless row (or column) of cards, logos with a mark, images or previews.
 * The list exists once for screen readers, the clones that close the loop are
 * hidden and inert. Pauses on hover and focus, and when out of view. With
 * reduced motion it is a still row you can scroll by touch, wheel or arrow keys.
 */
export function Marquee({ children, direction = 'horizontal', reverse, speed = 40, gap, fade = true, pauseOnHover = true, scrollShift = 0, label, labels, className, itemClassName }: MarqueeProps) {
  const t = useTexts(TEXTS, labels);
  const vertical = direction === 'vertical';
  const space = gap ?? (vertical ? 16 : 40);
  const box = useRef<HTMLDivElement>(null);
  const first = useRef<HTMLDivElement>(null);
  // How many copies close the loop at the current width, and how long one copy takes
  const [copies, setCopies] = useState(2);
  const [duration, setDuration] = useState(30);
  // One copy in px, the drift wrapper starts one copy early so both directions stay covered
  const [inner, setInner] = useState(0);

  useEffect(() => {
    const el = box.current;
    const copy = first.current;
    if (!el || !copy) return;
    const measure = () => {
      const outer = vertical ? el.clientHeight : el.clientWidth;
      const size = vertical ? copy.offsetHeight : copy.offsetWidth;
      if (!size) return;
      setCopies(Math.max(2, Math.ceil(outer / size) + 1) + (scrollShift ? 2 : 0));
      setDuration(Math.max(4, Math.round((size / speed) * 10) / 10));
      setInner(size);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    ro.observe(copy);
    const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
    io.observe(el);
    return () => {
      ro.disconnect();
      io.disconnect();
    };
  }, [vertical, speed, scrollShift]);

  // Drift: --kitfx-p runs from 0 (row enters at the bottom) to 1 (it leaves at the top)
  useEffect(() => {
    const el = box.current;
    if (!el || !scrollShift || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [scrollShift]);

  const mask = fade ? (vertical ? 'linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent)' : undefined) : undefined;
  const items = Children.toArray(children);
  const list = (clone: boolean, i: number) => (
    <div
      key={i}
      ref={clone ? undefined : first}
      data-copy={clone ? 'clone' : 'main'}
      aria-hidden={clone || undefined}
      inert={clone || undefined}
      className={cn('kitfx-mq-copy flex shrink-0', vertical ? 'flex-col' : 'items-center')}
      style={vertical ? { gap: space, paddingBottom: space } : { gap: space, paddingRight: space }}
      role={clone ? undefined : 'list'}
    >
      {items.map((child, k) => (
        <div key={k} role={clone ? undefined : 'listitem'} className={cn('shrink-0', itemClassName)}>
          {child}
        </div>
      ))}
    </div>
  );

  // The drift sits on its own wrapper, so the run keeps its transform
  const track = (
    <div
      className={cn('kitfx-mq-track flex', vertical ? 'w-full flex-col' : 'w-max')}
      style={{
        ['--kitfx-n' as string]: copies,
        animation: `${vertical ? 'kitfx-marquee-y' : 'kitfx-marquee-x'} ${duration}s linear infinite${reverse ? ' reverse' : ''}`,
      }}
    >
      {Array.from({ length: copies }, (_, i) => list(i > 0, i))}
    </div>
  );
  // One copy early, which looks the same, so the drift never uncovers an edge
  const drift = `calc(${-inner}px + (0.5 - var(--kitfx-p, 0.5)) * ${scrollShift}px)`;

  // The focus ring sits on an outer wrapper, the masked edges would cut it off
  return (
    <div className={cn('relative rounded-lg has-[.kitfx-mq:focus-visible]:outline-2 has-[.kitfx-mq:focus-visible]:outline-offset-4 has-[.kitfx-mq:focus-visible]:outline-ring', className)}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div
        ref={box}
        role="region"
        aria-roledescription="marquee"
        aria-label={label ?? t.region}
        tabIndex={0}
        data-pause={pauseOnHover ? '' : undefined}
        className={cn('kitfx-mq kit-no-scrollbar relative size-full overflow-hidden outline-none', fade && !vertical && 'kit-fade-x')}
        style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}
      >
        <div data-clip-ok className={cn('kitfx-mq-shift', vertical ? 'w-full' : 'w-max')} style={scrollShift && inner ? { transform: vertical ? `translate3d(0, ${drift}, 0)` : `translate3d(${drift}, 0, 0)` } : undefined}>
          {track}
        </div>
      </div>
    </div>
  );
}

Frequent questions

How do I install Marquee?

With aiw add @ai-website/marquee. The command puts components/ui/marquee.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Marquee have?

3 variants, Logo row, Two rows of cards and Vertical columns.

Can I translate the texts of Marquee?

Yes. Marquee carries 1 texts of its own in English and German, such as “Scrolling list”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Marquee?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Marquee with aiw_components and install it with aiw_add @ai-website/marquee.

Often searched

Jump to

Copied to the clipboard