# Marquee

Auto-scrolling row that is also a real horizontal scroller, endless in both directions.

Framework: React (variant `react`)

In the Digital Design monorepo, copy the files; don't retype them. From the repo root, with <project> as the target project's folder:

```sh
mkdir -p <project>/src/components <project>/src/styles
cp library/src/content/snippets/marquee/files/src/components/Marquee.tsx <project>/src/components/Marquee.tsx
cp library/src/content/snippets/marquee/files/src/styles/marquee.css <project>/src/styles/marquee.css
```

## Dependencies

None.

Expects: react >=19.

## Setup

A native React version: it shares `marquee.css` with the other versions, and not their script. It renders on the server with the `copies` prop, and adds more copies in the browser on wide screens.
Copies set up their links when they mount. If the items change, give the component a new `key`.


## Usage

```astro
---
// client:load, not client:only: the component renders on the server and only
// reaches for the browser in its effects.
import { Marquee } from "/src/components/Marquee";
---

<Marquee client:load label="Demo partners" duration="30s" gap="2rem">
  <li>
    <a href="#one">Alpha</a>
  </li>
  <li>
    <a href="#two">Bravo</a>
  </li>
  <li>
    <a href="#three">Charlie</a>
  </li>
  <li>
    <a href="#four">Delta</a>
  </li>
  <li>
    <a href="#five">Echo</a>
  </li>
</Marquee>

```

## API

Shared by every variant. Wrapper props use the same names in camelCase.

### Attributes

| Name | Type | Description |
| --- | --- | --- |
| `data-marquee-direction` | `"reverse"` | Travel the other way. Leave it out to travel forward. The Astro and React versions take a `direction` prop. |
| `data-marquee-pause-on-hover` | `boolean attribute` | Pause while hovered, on pointer devices only. The Astro and React versions take `pauseOnHover`, on by default. |

### Custom properties

| Name | Default | Description |
| --- | --- | --- |
| `--marquee-duration` | `"60s"` | One full loop. The Astro and React versions set it from the `duration` prop, where a bare number is read as seconds. |
| `--marquee-gap` | `"1rem"` | Space between items, and between the end of one copy and the start of the next. The `gap` prop sets it. |
| `--marquee-control-size` | `2.5rem` | Diameter of the pause and play button. |
| `--marquee-control-bg` | `rgb(255 255 255 / 25%)` | Background of the pause and play button, behind the blur. |
| `--marquee-control-blur` | `8px` | Backdrop blur radius on the pause and play button. |
| `--marquee-control-color` | `currentColor` | Icon colour of the pause and play button, also used for its focus ring. Defaults to the marquee's text colour. |
| `--marquee-control-border-color` | `transparent` | Border colour of the pause and play button. Set it when the button needs a visible edge against busy content. |
| `--marquee-control-offset` | `0.75rem` | Distance from the pause and play button to the right edge. |

## Accessibility

The row is keyboard focusable and scrolls with the arrow keys. It pauses when hovered, when it or an item in it has keyboard focus, when scrolled by hand and when off screen. Reduced motion stops the animation, hides the pause button and leaves the row scrollable. Copies are `aria-hidden` and their links are out of the tab order. A pause and play button sits near the right edge: visible on hover or keyboard focus, while paused from the button, and always on touch, where there is no hover to reveal it. It is a real `button` with an accessible name that swaps between "Pause" and "Play". Without JavaScript the button stays hidden, and hover and keyboard focus still pause the row. The page still has to name the row itself, with the `label` prop or `aria-label`.

## SEO

Items are in the HTML. The CSS loads in the head and changes only overflow and animation, so nothing shifts when the script runs. Copies are `aria-hidden`, but crawlers still read them. The page still has to keep items short, with no headings or long copy.

## Notes

Manual scrolling is endless in both directions. The script moves the scroll position by one track width near either end, and the jump does not show because every copy is identical.

The HTML and Astro versions share one small script, `initMarquees()`, found by the `data-marquee` attribute. The React version is its own implementation, so it follows React's rules rather than wrapping the script. All three share `marquee.css`.

## Files

### src/components/Marquee.tsx

```tsx
/**
 * Marquee for React: a native version, not a wrapper around the HTML script.
 * It shares `marquee.css` with the other versions, so it looks and moves the
 * same way.
 *
 *   <Marquee label="Partners" duration="40s">
 *     <li>One</li>
 *     <li>Two</li>
 *   </Marquee>
 *
 * How it stays fast: CSS runs the animation, so React never renders per frame.
 * State only changes when the row pauses, resumes, goes off screen or needs
 * more copies. Refs handle the scroll position.
 *
 * Copies take the tab-key changes they need when they first mount. If the
 * items change, give the component a new `key`, so every copy starts fresh.
 */
import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ElementType,
  type ReactNode,
  type RefObject,
} from "react";
import "../styles/marquee.css";

const RESUME_AFTER_SCROLLEND = 600;
const RESUME_FALLBACK = 2000;
const MAX_TRACKS = 24;
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]";

interface MarqueeProps {
  /** Element used for the track. Children are its items. */
  as?: "ul" | "ol" | "div";
  /** One loop of the animation, such as `60s`. A bare number is seconds. */
  duration?: string | number;
  /** Space between items and between copies of the track. */
  gap?: string;
  direction?: "forward" | "reverse";
  /** Pause the animation on hover, on pointer devices only. */
  pauseOnHover?: boolean;
  /** Copies of the track to render at first, on the server too. More are added on wide screens. */
  copies?: number;
  /** Names the scrollable region for screen readers and keyboard users. */
  label?: string;
  children: ReactNode;
}

/* ---- Helpers that work on the DOM, outside React's render ---- */

/** From the start of one copy to the start of the next. */
function periodOf(root: HTMLElement, track: HTMLElement) {
  const gap = parseFloat(getComputedStyle(root).columnGap) || 0;
  return track.getBoundingClientRect().width + gap;
}

/** Moves the scroll position without it counting as the user scrolling. */
function setScrollLeft(root: HTMLElement, selfScrolling: RefObject<boolean>, value: number) {
  selfScrolling.current = true;
  root.scrollLeft = value;
  requestAnimationFrame(() => (selfScrolling.current = false));
}

/**
 * Runs when a copy mounts. Copies are hidden from screen readers by
 * `aria-hidden`, and this takes their links out of the tab order. It also
 * starts the copy's animation in step with the others, so the loop has no seam.
 */
function prepareCopy(copy: HTMLElement | null) {
  if (!copy) return;
  copy.querySelectorAll<HTMLElement>(FOCUSABLE).forEach((element) => (element.tabIndex = -1));
  const source = copy.parentElement?.firstElementChild?.getAnimations()[0];
  const fresh = copy.getAnimations()[0];
  if (source && fresh) fresh.currentTime = source.currentTime;
}

export function Marquee({
  as = "ul",
  duration,
  gap,
  direction = "forward",
  pauseOnHover = true,
  copies = 3,
  label,
  children,
}: MarqueeProps) {
  const Track = as as ElementType;
  const rootRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLElement>(null);
  const selfScrolling = useRef(false);

  // Starts at `copies`, the same on the server and in the browser, so hydration matches.
  const [tracks, setTracks] = useState(() => Math.max(1, copies));
  const [scrollPaused, setScrollPaused] = useState(false);
  const [userPaused, setUserPaused] = useState(false);
  const [offscreen, setOffscreen] = useState(false);
  const paused = scrollPaused || userPaused;

  // Add copies until a period of runway covers either side of the view.
  useEffect(() => {
    const root = rootRef.current;
    const track = trackRef.current;
    if (!root || !track) return;
    const observer = new ResizeObserver(() => {
      const step = periodOf(root, track);
      if (!step) return;
      const needed = Math.min(MAX_TRACKS, Math.ceil((root.clientWidth + 3 * step) / step));
      setTracks((current) => Math.max(current, needed));
    });
    observer.observe(root);
    return () => observer.disconnect();
  }, []);

  // Once there are enough copies, start one period in, so there is content to
  // reveal in both directions. Before paint, so the row never visibly jumps.
  useLayoutEffect(() => {
    const root = rootRef.current;
    const track = trackRef.current;
    if (!root || !track) return;
    const step = periodOf(root, track);
    if (step && root.scrollWidth - root.clientWidth >= 2 * step && root.scrollLeft < step) {
      setScrollLeft(root, selfScrolling, step);
    }
  }, [tracks]);

  // Pause while the user scrolls, and keep the scroll position endless.
  useEffect(() => {
    const root = rootRef.current;
    const track = trackRef.current;
    if (!root || !track) return;
    let resumeTimer: ReturnType<typeof setTimeout> | undefined;
    const resumeAfter = (delay: number) => {
      clearTimeout(resumeTimer);
      resumeTimer = setTimeout(() => setScrollPaused(false), delay);
    };

    const onScroll = () => {
      if (selfScrolling.current) return;
      setScrollPaused(true);
      const step = periodOf(root, track);
      if (step && root.scrollWidth - root.clientWidth >= 2 * step) {
        if (root.scrollLeft < step) setScrollLeft(root, selfScrolling, root.scrollLeft + step);
        else if (root.scrollLeft >= 2 * step)
          setScrollLeft(root, selfScrolling, root.scrollLeft - step);
      }
      // In case scrollend never fires, such as in an older Safari.
      resumeAfter(RESUME_FALLBACK);
    };
    const onScrollEnd = () => resumeAfter(RESUME_AFTER_SCROLLEND);

    root.addEventListener("scroll", onScroll, { passive: true });
    root.addEventListener("scrollend", onScrollEnd);
    return () => {
      root.removeEventListener("scroll", onScroll);
      root.removeEventListener("scrollend", onScrollEnd);
      clearTimeout(resumeTimer);
    };
  }, []);

  // Do not spend frames on a row nobody can see.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), {
      rootMargin: "200px",
    });
    observer.observe(root);
    return () => observer.disconnect();
  }, []);

  const knobs = {
    "--marquee-duration":
      duration === undefined
        ? undefined
        : /^[\d.]+$/.test(String(duration))
          ? `${duration}s`
          : duration,
    "--marquee-gap": gap,
  } as CSSProperties;

  const role = as === "div" ? undefined : "list";

  return (
    <div
      ref={rootRef}
      data-marquee=""
      data-marquee-direction={direction === "reverse" ? "reverse" : undefined}
      data-marquee-pause-on-hover={pauseOnHover ? "" : undefined}
      data-marquee-paused={paused ? "" : undefined}
      data-marquee-user-paused={userPaused ? "" : undefined}
      data-marquee-offscreen={offscreen ? "" : undefined}
      role={label ? "group" : undefined}
      aria-label={label}
      tabIndex={0}
      style={knobs}
    >
      <Track ref={trackRef} role={role}>
        {children}
      </Track>
      {Array.from({ length: tracks - 1 }, (_, index) => (
        <Track key={index} ref={prepareCopy} role={role} data-marquee-copy="" aria-hidden="true">
          {children}
        </Track>
      ))}
      <button
        type="button"
        data-marquee-control=""
        aria-label={paused ? "Play" : "Pause"}
        onClick={() => {
          if (paused) {
            setUserPaused(false);
            setScrollPaused(false);
          } else {
            setUserPaused(true);
          }
        }}
      >
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
          <path fill="currentColor" d={paused ? "M8 5v14l11-7z" : "M6 5h4v14H6zm8 0h4v14h-4z"} />
        </svg>
      </button>
    </div>
  );
}

```

### src/styles/marquee.css

```css
/*
 * Marquee: an auto-scrolling row that is also a real horizontal scroller.
 * Shared by the HTML, Astro and React versions. Load it in the head.
 *
 * Markup: a [data-marquee] element whose first child is the track. Copies of
 * the track follow it, marked [data-marquee-copy]. The row only moves once a
 * copy exists, so a single authored track sits still until the script (or the
 * Astro component, on the server) has added copies.
 *
 *   <div data-marquee role="group" aria-label="Partners" tabindex="0">
 *     <ul role="list">...</ul>
 *   </div>
 *
 * Knobs, set on the element or any ancestor:
 *   --marquee-gap                    Space between items and between copies. Default 1rem.
 *   --marquee-duration               One loop. Default 60s.
 *   --marquee-control-size           Pause button diameter. Default 2.5rem.
 *   --marquee-control-bg             Pause button background. Default rgb(255 255 255 / 25%).
 *   --marquee-control-blur           Backdrop blur behind the button. Default 8px.
 *   --marquee-control-color          Icon and focus ring colour. Default currentColor.
 *   --marquee-control-border-color   Button border. Default transparent.
 *   --marquee-control-offset         Distance from the right edge. Default 0.75rem.
 *
 * Options as attributes:
 *   data-marquee-direction="reverse"   Travel the other way.
 *   data-marquee-pause-on-hover        Pause while hovered, on pointer devices.
 *
 * Unlayered and scoped to data-marquee attributes, so a page's reset cannot
 * undo it. Change the look through the knobs above.
 */

[data-marquee] {
  --_marquee-gap: var(--marquee-gap, 1rem);
  --_marquee-duration: var(--marquee-duration, 60s);

  position: relative;
  display: flex;
  align-items: start;
  gap: var(--_marquee-gap);
  /* Horizontal only: overflow-x alone would compute overflow-y to auto as
     well, so sub-pixel overflow would get its own vertical scroller. */
  overflow: auto hidden;
  overscroll-behavior-inline: contain;
  scrollbar-width: none;

  &::-webkit-scrollbar {
    display: none;
  }

  /* The track and its copies, whatever element they are. The pause button
     is positioned out of flow, so it is left out. */
  > :not([data-marquee-control]) {
    display: flex;
    flex: 0 0 auto;
    gap: var(--_marquee-gap);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  > :not([data-marquee-control]) > * {
    flex: 0 0 auto;
  }

  /* Only move once there is a copy to loop into, and only for people who
     have not asked for less motion. The row stays scrollable either way. */
  @media (prefers-reduced-motion: no-preference) {
    &:has(> [data-marquee-copy]) > :not([data-marquee-control]) {
      animation: marquee-scroll var(--_marquee-duration) linear infinite;
    }
  }

  &[data-marquee-direction="reverse"] > :not([data-marquee-control]) {
    animation-direction: reverse;
  }

  /* Paused: while the user scrolls or pressed pause, while the row or an item
     in it has keyboard focus, on hover where hovering is meaningful, and when
     off screen. Focus on the pause button doesn't count, or pressing play
     would do nothing until the button lost focus. Mouse focus doesn't either:
     clicking the row focuses it (it has a tabindex), which would pause it
     until the next click elsewhere. */
  &[data-marquee-paused] > :not([data-marquee-control]),
  &[data-marquee-offscreen] > :not([data-marquee-control]),
  &:focus-visible > :not([data-marquee-control]),
  &:has(:focus-visible:not([data-marquee-control])) > :not([data-marquee-control]) {
    animation-play-state: paused;
  }

  @media (hover: hover) {
    &[data-marquee-pause-on-hover]:hover > :not([data-marquee-control]) {
      animation-play-state: paused;
    }
  }

  /* The pause button. The row is its own scroller, so an absolutely
     positioned child would scroll away with the content. Instead it is the
     last flex item, stuck to the right edge of the scrollport, with negative
     margins so it takes up no room in the row. */
  > [data-marquee-control] {
    --_marquee-control-color: var(--marquee-control-color, currentColor);
    --_marquee-control-size: var(--marquee-control-size, 2.5rem);

    position: sticky;
    inset-inline-end: var(--marquee-control-offset, 0.75rem);
    z-index: 1;
    flex: 0 0 auto;
    align-self: center;
    margin-inline-start: calc(-1 * (var(--_marquee-control-size) + var(--_marquee-gap)));
    margin-block: calc(-0.5 * var(--_marquee-control-size));

    display: grid;
    place-items: center;
    inline-size: var(--_marquee-control-size);
    block-size: var(--_marquee-control-size);
    padding: 0;
    border: 1px solid var(--marquee-control-border-color, transparent);
    border-radius: 999px;
    background: var(--marquee-control-bg, rgb(255 255 255 / 25%));
    backdrop-filter: blur(var(--marquee-control-blur, 8px));
    color: var(--_marquee-control-color);
    opacity: 0;
    cursor: pointer;
    transition: opacity 150ms ease;

    &:focus-visible {
      outline: 2px solid var(--_marquee-control-color);
      outline-offset: 2px;
    }

    /* The button does nothing without its script, so it stays hidden until then. */
    &[hidden] {
      display: none;
    }

    svg {
      inline-size: 40%;
      block-size: 40%;
      pointer-events: none;
    }
  }

  /* Shown on hover and focus, and kept up while paused from the button, so a
     stopped row reads as paused rather than broken. */
  &:hover > [data-marquee-control],
  &:focus-within > [data-marquee-control],
  &[data-marquee-user-paused] > [data-marquee-control] {
    opacity: 1;
  }

  /* No hover to reveal it on touch, so it stays visible. */
  @media (hover: none) {
    > [data-marquee-control] {
      opacity: 1;
    }
  }

  /* Nothing moves with reduced motion, so there is nothing to pause. */
  @media (prefers-reduced-motion: reduce) {
    > [data-marquee-control] {
      display: none;
    }
  }
}

@keyframes marquee-scroll {
  to {
    translate: calc(-100% - var(--_marquee-gap)) 0;
  }
}

```
