# Horizontal Scroll (Moonarc)

A strip of items slides sideways while the page scrolls down and the section stays pinned. Zero JavaScript: a sticky viewport and a translate bound to the section's view timeline. Where scroll timelines are missing it is a normal horizontally scrollable strip.

- Import: `import HorizontalScroll from '@moonarc/core/HorizontalScroll'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/horizontal-scroll.json`
- Tier A · category scroll · trigger scroll
- Readout: `<HorizontalScroll length={3}>`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: a horizontally scrollable strip without pinning (Firefox)
- Measured cost: 0 B JS (CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/horizontal-scroll/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `length` | `number` | `3` | How many viewport heights the section stays pinned for. Roughly one per item. |
| `gap` | `string` | `'2rem'` | Gap between items. |
| `viewport` | `string` | `'100vh'` | Height of the pinned viewport: the page, or an enclosing scroller's height. |
| `label` | `string` | `'Horizontal strip'` | Accessible name of the strip, which is a tab stop and a region. Say what it holds ("Case studies"). |

## Usage

```astro
<HorizontalScroll length={3} gap="2rem" label="Case studies">
  <article class="w-[70vw]">One</article>
  <article class="w-[70vw]">Two</article>
  <article class="w-[70vw]">Three</article>
</HorizontalScroll>
```

## Reduced motion

Not pinned; the strip scrolls horizontally by hand.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Where it falls back to a scrollable strip (no scroll timelines, or reduced motion) the viewport is a tab stop, so the strip scrolls from the keyboard too.
- Pinned, the viewport clips (overflow: clip, not hidden, so it is not a scroll container), and keyboard focus inside it turns it into the scrollable strip until focus leaves: the browser scrolls a focused item into view, and nothing stays scrolled on top of the translate afterwards. A click on a link or a button changes nothing; a text field shows focus however it gets it, so clicking one does.
- The travel is min(0px, 100cqw - 100%): the track's own width minus the sticky viewport's, measured by the browser through a container query unit, with nothing computed in script; a track narrower than the viewport stays put. Right to left (a dir="rtl" on the page or an ancestor) the same distance is mirrored, because the track starts at the right edge.
- animation-range contain 0% → 100%: the strip moves only while the section fully covers the viewport, so it never starts before it is pinned.
- The section's height is the scroll budget; length ≈ item count keeps the sideways speed close to the scroll speed.

## Replaces

- GSAP ScrollTrigger pin + horizontal tween
- Locomotive horizontal sections

## Source

```astro
---
/**
 * HorizontalScroll — a strip that moves sideways as the page scrolls down.
 *
 * The section is tall; inside it a sticky viewport holds a max-content
 * track whose translate is bound to the section's view timeline, so the
 * track crosses exactly while the section is pinned. Zero JS. Browsers
 * without scroll timelines get a normal horizontally scrollable strip — a tab
 * stop, so the arrow keys scroll it (reduced motion gets the same strip).
 * Keyboard focus inside the pinned strip turns it into that strip for as long
 * as it stays, so a focused item is scrolled into view by the browser.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'section'> {
  /** Scroll distance as viewport heights: how long the strip stays pinned. */
  length?: number;
  /** Gap between items, any CSS length. */
  gap?: string;
  /** Height of the pinned viewport; 100vh for the page, or the height of an enclosing scroller. */
  viewport?: string;
  /** Accessible name of the strip, which is a tab stop: say what it holds ("Case studies"). */
  label?: string;
}

const { length = 3, gap = '2rem', viewport = '100vh', label = 'Horizontal strip', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-hs-len:${length}`, `--ma-hs-gap:${gap}`, `--ma-hs-vp:${viewport}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<section class:list={['ma-hscroll', className]} style={vars} {...rest}>
  <div class="ma-hscroll__viewport" tabindex="0" role="region" aria-label={label}>
    <div class="ma-hscroll__track">
      <slot />
    </div>
  </div>
</section>

<style is:global>
  @layer components {
    :where(.ma-hscroll__viewport) {
      overflow-x: auto;
      container-type: inline-size;
    }
    :where(.ma-hscroll__track) {
      display: flex;
      align-items: center;
      gap: var(--ma-hs-gap, 2rem);
      width: max-content;
      height: 100%;
    }
    @supports (animation-timeline: view()) {
      :where(.ma-hscroll) {
        height: calc(var(--ma-hs-vp, 100vh) * var(--ma-hs-len, 3));
        view-timeline-name: --ma-hs;
      }
      /* clip, not hidden: a hidden box is still a scroll container, so a focused item scrolled it sideways and the offset
         stayed on top of the translate after focus left */
      :where(.ma-hscroll__viewport) {
        position: sticky;
        top: 0;
        height: var(--ma-hs-vp, 100vh);
        overflow: clip;
      }
      :where(.ma-hscroll__track) {
        will-change: translate;
        animation: ma-hscroll linear both;
        animation-range: contain 0% contain 100%;
      }
      :where(html) :where(.ma-hscroll__track) {
        animation-timeline: --ma-hs;
      }
      /* right to left the track starts at the right edge, so it travels right: the same distance, mirrored. [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-hscroll, .ma-hscroll[dir='rtl']) {
        --ma-hs-dir: -1;
      }
      :where([dir='rtl'] [dir='ltr'] .ma-hscroll, [dir='rtl'] .ma-hscroll[dir='ltr']) {
        --ma-hs-dir: 1;
      }
      /* keyboard focus in the strip (the strip itself, or an item) makes it the scrollable strip until focus leaves: the
         browser scrolls the focused item into view, and the offset goes when clip comes back. A click on a link or a
         button does not (a text field matches :focus-visible however it is focused) */
      :where(.ma-hscroll__viewport:is(:focus-visible, :has(:focus-visible))) {
        overflow-x: auto;
      }
      :where(.ma-hscroll__viewport:is(:focus-visible, :has(:focus-visible)) .ma-hscroll__track) {
        animation: none;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-hscroll) {
        height: auto;
      }
      :where(.ma-hscroll__viewport) {
        position: static;
        height: auto;
        overflow-x: auto;
      }
      :where(.ma-hscroll__track) {
        animation: none;
      }
    }
  }

  /* min(): a track narrower than the viewport stays put instead of travelling the wrong way */
  @keyframes ma-hscroll {
    to {
      translate: calc(var(--ma-hs-dir, 1) * min(0px, 100cqw - 100%)) 0;
    }
  }
</style>

```
