# Parallax (Moonarc)

An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll. Zero JavaScript: a translate bound to the element's own view timeline. Stack a few with different speeds for depth.

- Import: `import Parallax from '@moonarc/core/Parallax'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/parallax.json`
- Tier A · category scroll · trigger scroll
- Readout: `<Parallax speed={0.3}>`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: the element stays in place (Firefox)
- Measured cost: 0 B JS (CSS 4.6 kB raw)
- Page: https://moonarc.dev/components/parallax/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `speed` | `number` | `0.3` | Drift over the element's full passage, as a fraction of the viewport: 0.3 travels 30 vh. Negative moves against the scroll. Keep |speed| under 0.5 or the element visits places it should not. |
| `axis` | `'y' | 'x'` | `'y'` | Axis of travel. For x, give the parent overflow-x: clip: a translated element still counts toward the page's scrollable width, so the drift would add a horizontal scrollbar. |

## Usage

```astro
<div class="relative">
  <Parallax speed={-0.2} class="absolute inset-0"><img src="/bg.jpg" alt="" /></Parallax>
  <Parallax speed={0.4}><h2 class="relative">Foreground moves faster</h2></Parallax>
</div>

<!-- sideways: clip the row, or the drift widens the page and it scrolls horizontally -->
<div class="overflow-x-clip">
  <Parallax axis="x" speed={0.2}><p class="text-6xl">Moving type</p></Parallax>
</div>
```

## Reduced motion

No drift; the element scrolls with the page.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- view() with range cover 0% → 100%: the drift starts the moment the element enters and ends the moment it leaves, so it is never mid-motion while static.
- Linear timing: scroll-linked motion must map 1:1 to the scroll or it feels detached.
- translate, not top: the compositor moves it; layout is untouched.
- The parent must reserve the space the element drifts through, or it will overlap neighbours. Sideways, it must also clip (overflow-x: clip, which unlike hidden makes no scroll container and so keeps the view timeline on the page), or the drift widens the page.

## Replaces

- react-scroll-parallax
- Rellax
- GSAP ScrollTrigger for simple parallax

## Source

```astro
---
/**
 * Parallax — an element drifts at its own speed as it crosses the viewport.
 *
 * animation-timeline: view() binds a translate to the element's own passage
 * through the scrollport, so the drift is exact, never lags, and costs no
 * scroll listener. Browsers without scroll timelines show it in place.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Drift as a fraction of the viewport, −1…1. Negative moves against the scroll. */
  speed?: number;
  /** Axis of travel. */
  axis?: 'y' | 'x';
}

const { as: Tag = 'div', speed = 0.3, axis = 'y', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-px-speed:${speed}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-parallax', className]} data-axis={axis === 'x' ? 'x' : undefined} style={inline} {...rest}>
  <slot />
</Tag>

<style is:global>
  @layer components {
    @supports (animation-timeline: view()) {
      :where(.ma-parallax) {
        will-change: translate;
        animation: ma-parallax-y linear both;
      }
      :where(.ma-parallax[data-axis='x']) {
        animation-name: ma-parallax-x;
      }
      /* Separate rule on purpose: lightningcss folds animation-timeline into
         the shorthand, which browsers reject. Distinct selector text keeps them apart. */
      :where(html) :where(.ma-parallax) {
        animation-timeline: view();
        animation-range: cover 0% cover 100%;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-parallax) {
        animation: none;
        will-change: auto;
      }
    }
  }

  @keyframes ma-parallax-y {
    from {
      translate: 0 calc(var(--ma-px-speed, 0.3) * 50vh);
    }
    to {
      translate: 0 calc(var(--ma-px-speed, 0.3) * -50vh);
    }
  }
  @keyframes ma-parallax-x {
    from {
      translate: calc(var(--ma-px-speed, 0.3) * 50vw) 0;
    }
    to {
      translate: calc(var(--ma-px-speed, 0.3) * -50vw) 0;
    }
  }
</style>

```
