# Marquee

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

Framework: HTML (variant `html`)

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/scripts <project>/src/styles
cp library/src/content/snippets/marquee/files/src/styles/marquee.css <project>/src/styles/marquee.css
cp library/src/content/snippets/marquee/files/src/scripts/marquee.ts <project>/src/scripts/marquee.ts
```

## Dependencies

None.

## Setup

1. Load `src/styles/marquee.css` in the head.
2. Author one track inside `<div data-marquee role="group" aria-label="...">`.
3. Call `initMarquees()` from `src/scripts/marquee.ts` once the page has loaded. It sets up every `[data-marquee]`, and returns a clean-up function.
The row sits still until the script adds copies of the track.


## Usage

```astro
---
// Plain markup, the way a page without a framework would use it: one track,
// the CSS in the head, and the script to set it up.
import "/src/styles/marquee.css";
---

<div
  data-marquee
  data-marquee-pause-on-hover
  role="group"
  aria-label="Demo partners"
  style="--marquee-duration: 30s; --marquee-gap: 2rem"
>
  <ul role="list">
    <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>
  </ul>
</div>

<script>
  import { initMarquees } from "/src/scripts/marquee";
  initMarquees();
</script>

```

## 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/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;
  }
}

```

### src/scripts/marquee.ts

```ts
/**
 * Marquee: the script for the HTML and Astro versions. Load `marquee.css` in
 * the head as well.
 *
 *   import { initMarquees } from "./marquee";
 *   initMarquees(); // every [data-marquee] on the page
 *
 * The CSS runs the animation. This adds what CSS cannot do:
 * - clones the track until a wide screen is covered, and hides the copies from
 *   screen readers and the tab key;
 * - makes manual scrolling endless in both directions, by moving the scroll
 *   position one track width whenever it nears either end. Every copy is
 *   identical, so the jump does not show;
 * - pauses while the user scrolls, and when the row is off screen;
 * - adds a pause and play button.
 *
 * Calling init twice on the same element does nothing. Each call returns a
 * clean-up function that undoes it, for pages that swap content in and out.
 */

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

const PLAY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>`;
const PAUSE_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>`;

const active = new WeakMap<HTMLElement, () => void>();

/**
 * Copies are decoration: hidden from assistive tech and skipped by the tab key,
 * but still hoverable and clickable. A copy is often what is under the pointer,
 * so `inert`, which blocks clicks, is wrong here.
 */
function markCopy(copy: HTMLElement) {
  copy.setAttribute("data-marquee-copy", "");
  copy.setAttribute("aria-hidden", "true");
  copy.querySelectorAll<HTMLElement>(FOCUSABLE).forEach((element) => (element.tabIndex = -1));
}

function createControl(root: HTMLElement, added: Element[]): HTMLButtonElement {
  const button = document.createElement("button");
  button.type = "button";
  button.setAttribute("data-marquee-control", "");
  root.append(button);
  added.push(button);
  return button;
}

/** Sets up one marquee. Returns a function that undoes it. */
export function initMarquee(root: HTMLElement): () => void {
  const existing = active.get(root);
  if (existing) return existing;

  const track = root.firstElementChild as HTMLElement | null;
  if (!track) return () => {};

  const controller = new AbortController();
  const { signal } = controller;
  const added: Element[] = [];
  let resumeTimer: ReturnType<typeof setTimeout> | undefined;
  let selfScrolling = false;
  let userPaused = false;

  // A scrollable region must be reachable by keyboard, so it can be panned
  // with the arrow keys. Naming it (`aria-label`) is left to the page.
  if (!root.hasAttribute("tabindex")) root.tabIndex = 0;

  root
    .querySelectorAll<HTMLElement>(":scope > [data-marquee-copy]")
    .forEach((copy) => markCopy(copy));

  /* ---- Pause button ---- */
  // The Astro version renders the button, hidden until now. Plain HTML may leave it out.
  const control =
    root.querySelector<HTMLButtonElement>(":scope > [data-marquee-control]") ??
    createControl(root, added);
  control.hidden = false;

  const updateControl = () => {
    const paused = root.hasAttribute("data-marquee-paused");
    const label = paused ? "Play" : "Pause";
    // Scrolling calls this on every event, so skip it when nothing changed.
    if (control.getAttribute("aria-label") === label) return;
    control.setAttribute("aria-label", label);
    control.innerHTML = paused ? PLAY_ICON : PAUSE_ICON;
  };

  const pause = () => {
    clearTimeout(resumeTimer);
    root.setAttribute("data-marquee-paused", "");
    updateControl();
  };

  const resume = (delay = 0) => {
    // A pause from the button holds until the button is pressed again.
    if (userPaused) return;
    clearTimeout(resumeTimer);
    const run = () => {
      root.removeAttribute("data-marquee-paused");
      updateControl();
    };
    if (delay) resumeTimer = setTimeout(run, delay);
    else run();
  };

  control.addEventListener(
    "click",
    () => {
      userPaused = !root.hasAttribute("data-marquee-paused");
      root.toggleAttribute("data-marquee-user-paused", userPaused);
      if (userPaused) pause();
      else resume();
    },
    { signal },
  );
  updateControl();

  /* ---- Copies and endless scrolling ---- */

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

  const setScrollLeft = (value: number) => {
    selfScrolling = true;
    root.scrollLeft = value;
    requestAnimationFrame(() => (selfScrolling = false));
  };

  /** Clones the track until there is a period of runway either side of the view. */
  const fill = () => {
    const step = period();
    if (!step) return;
    let budget = MAX_COPIES - root.children.length;
    while (root.scrollWidth < root.clientWidth + 3 * step && budget-- > 0) {
      const copy = track.cloneNode(true) as HTMLElement;
      root.insertBefore(copy, control);
      markCopy(copy);
      added.push(copy);
      // A new copy starts its animation at zero. Match the track, so every copy
      // stays in step and the scroll jump stays invisible.
      const [source] = track.getAnimations();
      const [fresh] = copy.getAnimations();
      if (source && fresh) fresh.currentTime = source.currentTime;
    }
  };

  const layout = () => {
    fill();
    // Start one period in, so there is content to reveal in both directions.
    const step = period();
    if (step && root.scrollLeft < step) setScrollLeft(step);
  };

  /** Keeps the scroll position between one and two periods in. */
  const wrap = () => {
    const step = period();
    if (!step || root.scrollWidth - root.clientWidth < 2 * step) return;
    if (root.scrollLeft < step) setScrollLeft(root.scrollLeft + step);
    else if (root.scrollLeft >= 2 * step) setScrollLeft(root.scrollLeft - step);
  };

  root.addEventListener(
    "scroll",
    () => {
      if (selfScrolling) return;
      pause();
      wrap();
      // In case scrollend never fires, such as in an older Safari.
      resume(RESUME_FALLBACK);
    },
    { passive: true, signal },
  );
  root.addEventListener("scrollend", () => resume(RESUME_AFTER_SCROLLEND), { signal });

  // Track widths settle after layout and change with the viewport.
  const resizeObserver = new ResizeObserver(layout);
  resizeObserver.observe(root);

  // Do not spend frames on a row nobody can see.
  const intersectionObserver = new IntersectionObserver(
    ([entry]) => root.toggleAttribute("data-marquee-offscreen", !entry.isIntersecting),
    { rootMargin: "200px" },
  );
  intersectionObserver.observe(root);

  const cleanUp = () => {
    controller.abort();
    resizeObserver.disconnect();
    intersectionObserver.disconnect();
    clearTimeout(resumeTimer);
    added.forEach((element) => element.remove());
    for (const name of [
      "data-marquee-paused",
      "data-marquee-user-paused",
      "data-marquee-offscreen",
    ]) {
      root.removeAttribute(name);
    }
    active.delete(root);
  };
  active.set(root, cleanUp);
  return cleanUp;
}

/** Sets up every `[data-marquee]` inside `scope`. Returns one function that undoes them all. */
export function initMarquees(scope: ParentNode = document): () => void {
  const cleanUps = Array.from(scope.querySelectorAll<HTMLElement>("[data-marquee]"), initMarquee);
  return () => cleanUps.forEach((cleanUp) => cleanUp());
}

```
