# Marquee (Moonarc)

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.

- Import: `import Marquee from '@moonarc/core/Marquee'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/marquee.json`
- Tier A · category loop · trigger always, hover
- Readout: `<Marquee duration={16}>`
- Browser support: widely (Chrome 120 · Firefox 53 · Safari 15.4)
- Measured cost: 0 B JS (CSS 6.0 kB raw)
- Page: https://moonarc.dev/components/marquee/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `duration` | `number` | `28` | Seconds 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. |
| `repeat` | `number` | `2` | Copies 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. |
| `gap` | `string` | `'2rem'` | Space between items and between the copies. |
| `pauseOnHover` | `boolean` | `true` | Pause while hovered, on pointer devices only. |
| `fade` | `boolean` | `true` | Fade the strip edges with a mask. |
| `label` | `string` | none | Accessible name; the strip becomes a named region ("Customers"). |

## Usage

```astro
<Marquee duration={28} gap="3rem">
  <img src="/logos/a.svg" alt="A" />
  <img src="/logos/b.svg" alt="B" />
</Marquee>
```

## 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.

## Craft

- Linear timing: constant motion must not ease, or the loop point shows as a hitch.
- The track is at least the strip plus one copy's share wide (twice the strip with two copies), a percentage of the strip, and the copies grow to fill it with space-around. So the loop never runs out of content, whatever the content's width, and nothing is measured; the half spaces at a copy's two ends add up to one full space across the seam.
- The copies after the first are aria-hidden and inert, so screen readers hear each item once and Tab never reaches a link twice.
- The strip is a tab stop unless it is aria-hidden: focus pauses it (WCAG 2.2.2 wants a way to stop moving content, and a keyboard cannot hover), and under reduced motion (where it is a scrollable strip) the arrow keys scroll it. label names it as a region.
- Hover-pause is gated on (hover: hover) so touch devices never freeze it on tap.
- contain: layout paint keeps the moving track from invalidating the page around it.

## Replaces

- LogoLoop (React Bits)
- Marquee (Magic UI)
- InfiniteMovingCards (Aceternity)
- react-fast-marquee

## Source

```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>

```
