Skip to content

Components / Ticker

Marquee

Infinite scrolling strip for logos, quotes or tags in any direction. The content is rendered twice (or repeat times) and the track moves by exactly one copy, so there is never a seam; a copy narrower than the strip stretches to fill it, so a short list never leaves a blank band. Pauses on hover; becomes a static scrollable strip under reduced motion. Pure CSS.

Live demo

AstroTailwindGSAPLenisView TransitionsMIT0 B
AstroTailwindGSAPLenisView TransitionsMIT0 B

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 120 · Firefox 53 · Safari 15.4; needs mask-image.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Marquee from '@moonarc/core/Marquee';
---
<Marquee duration={28} gap="3rem">
  <img src="/logos/a.svg" alt="A" />
  <img src="/logos/b.svg" alt="B" />
</Marquee>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/marquee.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Marquee.astro
---
/**
 * Marquee — seamless infinite strip, zero JS.
 *
 * The slot is rendered twice (or `repeat` times); the track translates by
 * exactly one copy, so the loop never shows a seam. The track is at least one
 * strip plus one copy's share wide and the copies stretch to fill it, spacing
 * their items evenly, so a copy narrower than the strip never leaves a blank
 * band: a percentage of the strip, nothing measured. Pauses on hover so a
 * reader can stop a logo or a link. Reduced motion turns it into a static,
 * scrollable strip.
 *
 * The strip is a tab stop (unless it is aria-hidden): focus pauses it — the
 * keyboard's way to stop moving content (WCAG 2.2.2) — and under reduced motion
 * the arrow keys scroll it. The extra copies are inert, so their links are
 * never reached twice.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Seconds for one full loop. Longer strips need more. */
  duration?: number;
  /** Travel direction; left and right are physical, the same on a right-to-left page. Up and down need a height on the strip. */
  direction?: 'left' | 'right' | 'up' | 'down';
  /** Copies of the content in the strip, at least 2. More copies make a short list denser; the strip is covered either way. */
  repeat?: number;
  /** Space between items and between the copies. Any CSS length. */
  gap?: string;
  /** Pause while hovered (pointer devices only). */
  pauseOnHover?: boolean;
  /** Fade the strip's edges. */
  fade?: boolean;
  /** Accessible name; makes the strip a named region ("Customers"). */
  label?: string;
}

const { duration = 28, direction = 'left', repeat = 2, gap = '2rem', pauseOnHover = true, fade = true, label, class: className, style, ...rest } = Astro.props;
const hidden = rest['aria-hidden'] === true || rest['aria-hidden'] === 'true';
const vertical = direction === 'up' || direction === 'down';
const reverse = direction === 'right' || direction === 'down';
const copies = Math.max(2, Math.round(repeat) || 2);
const vars = [`--ma-marquee-duration:${duration}s`, `--ma-marquee-gap:${gap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div
  class:list={['ma-marquee', className]}
  data-vertical={vertical ? '' : undefined}
  data-reverse={reverse ? '' : undefined}
  data-pause={pauseOnHover ? '' : undefined}
  data-fade={fade ? '' : undefined}
  tabindex={hidden ? undefined : 0}
  role={label ? 'region' : undefined}
  aria-label={label}
  style={vars}
  {...rest}
>
  <div class="ma-marquee__track" style={copies > 2 ? `--ma-marquee-copies:${copies}` : undefined}>
    <div class="ma-marquee__group"><slot /></div>
    {Array.from({ length: copies - 1 }, () => <div class="ma-marquee__group" aria-hidden="true" inert><slot /></div>)}
  </div>
</div>

<style is:global>
  @layer components {
    :where(.ma-marquee) {
      overflow: hidden;
      /* A flex/grid child defaults to min-width: auto = its min-content, and the
         track is max-content wide, so without this the strip leaks its whole
         width into the parent column and locks the grid. */
      min-width: 0;
      max-width: 100%;
      contain: layout paint;
    }
    :where(.ma-marquee[data-fade]) {
      mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
    }
    :where(.ma-marquee[data-fade][data-vertical]) {
      mask-image: linear-gradient(180deg, transparent, #000 10%, #000 90%, transparent);
    }
    /* at least n / (n - 1) strips wide for n copies (two copies: twice the strip), and the copies grow to fill it, so
       each is at least 1 / (n - 1) of the strip and the n - 1 copies in view always cover it: the loop never runs out
       of content before it wraps */
    :where(.ma-marquee__track) {
      display: flex;
      width: max-content;
      min-inline-size: calc(100% * var(--ma-marquee-copies, 2) / (var(--ma-marquee-copies, 2) - 1));
      animation: ma-marquee var(--ma-marquee-duration, 28s) linear infinite;
    }
    :where(.ma-marquee[data-vertical] .ma-marquee__track) {
      flex-direction: column;
      width: auto;
      height: max-content;
      min-inline-size: auto;
      /* a percentage of a definite height only: a strip with no height set has nothing to fill */
      min-block-size: calc(100% * var(--ma-marquee-copies, 2) / (var(--ma-marquee-copies, 2) - 1));
      animation-name: ma-marquee-vertical;
    }
    :where(.ma-marquee[data-reverse] .ma-marquee__track) {
      animation-direction: reverse;
    }
    /* right to left the track sits at the strip's right edge and the copies lie to the left of the first, so the loop
       starts one copy to the right and comes back to rest (--ma-marquee-rtl in the keyframes); left and right stay
       physical. [dir] and not :dir(): :dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a
       page that sets only dir never matches. An ltr island one level inside an rtl page is set back */
    :where([dir='rtl'] .ma-marquee, .ma-marquee[dir='rtl']) {
      --ma-marquee-rtl: 1;
    }
    :where([dir='rtl'] [dir='ltr'] .ma-marquee, [dir='rtl'] .ma-marquee[dir='ltr']) {
      --ma-marquee-rtl: 0;
    }
    /* space-around: a stretched copy spreads its items, and the half spaces at its two ends add up to one full space
       across the seam, so the rhythm is even all the way round */
    :where(.ma-marquee__group) {
      display: flex;
      flex: 1 0 auto;
      align-items: center;
      justify-content: space-around;
      gap: var(--ma-marquee-gap, 2rem);
      padding-inline-end: var(--ma-marquee-gap, 2rem);
    }
    :where(.ma-marquee[data-vertical] .ma-marquee__group) {
      flex-direction: column;
      padding-inline-end: 0;
      padding-block-end: var(--ma-marquee-gap, 2rem);
    }
    @media (hover: hover) and (pointer: fine) {
      :where(.ma-marquee[data-pause]:hover .ma-marquee__track) {
        animation-play-state: paused;
      }
    }
    /* focus pauses whatever pauseOnHover says: it is the keyboard's only way to stop the strip */
    :where(.ma-marquee:focus-within .ma-marquee__track) {
      animation-play-state: paused;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-marquee__track) {
        animation: none;
        min-inline-size: auto;
        min-block-size: auto;
      }
      :where(.ma-marquee) {
        overflow: auto;
        mask-image: none;
      }
      :where(.ma-marquee__group[aria-hidden]) {
        display: none;
      }
    }
  }

  /* one copy to the left: from rest, or right to left from one copy to the right back to rest */
  @keyframes ma-marquee {
    from {
      translate: calc(var(--ma-marquee-rtl, 0) * 100% / var(--ma-marquee-copies, 2)) 0;
    }
    to {
      translate: calc((var(--ma-marquee-rtl, 0) - 1) * 100% / var(--ma-marquee-copies, 2)) 0;
    }
  }
  @keyframes ma-marquee-vertical {
    to {
      translate: 0 calc(-100% / var(--ma-marquee-copies, 2));
    }
  }
</style>

Props

PropTypeDefaultDescription
durationnumber28Seconds for one full loop. Scale with content width: roughly 1 s per 60 px keeps logos readable.
direction'left' | 'right' | 'up' | 'down''left'Travel direction. Left and right are physical: the same on a right-to-left page. Up and down need a height on the strip (a class or a style), which the copies then fill.
repeatnumber2Copies of the content, at least 2. A copy narrower than the strip is stretched and its items spaced out; more copies keep a short list dense instead.
gapstring'2rem'Space between items and between the copies.
pauseOnHoverbooleantruePause while hovered, on pointer devices only.
fadebooleantrueFade the strip edges with a mask.
labelstringnoneAccessible name; the strip becomes a named region ("Customers").

Reduced motion

The strip stops, the extra copies are hidden, and the container becomes scrollable so nothing is unreachable.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: LogoLoop (React Bits) · Marquee (Magic UI) · InfiniteMovingCards (Aceternity) · react-fast-marquee. See the migration table.

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown