# Scroll Scrub (Moonarc)

Bind translate, scale, rotate and opacity to the scroll position: the element goes from the start values you give to neutral across a range of its own passage through the viewport, exactly in step. Zero JavaScript.

- Import: `import ScrollScrub from '@moonarc/core/ScrollScrub'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/scroll-scrub.json`
- Tier A · category scroll · trigger scroll
- Readout: `<ScrollScrub y={80} scale={0.9}>`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: the end state, static (Firefox)
- Measured cost: 0 B JS (CSS 5.0 kB raw)
- Page: https://moonarc.dev/components/scroll-scrub/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `x` | `number` | `0` | Start x offset in px. |
| `y` | `number` | `0` | Start y offset in px. |
| `scale` | `number` | `1` | Start scale. |
| `rotate` | `number` | `0` | Start rotation in degrees. |
| `opacity` | `number` | `1` | Start opacity. |
| `rotateX` | `number` | none | Start tilt on the X axis in degrees (the "container scroll" look); the element straightens across the range. Perspective is applied on the element itself, no wrapper. |
| `perspective` | `number` | `1200` | Perspective in px, with rotateX. |
| `range` | `string` | `'entry 0% cover 40%'` | animation-range on the element's view timeline: which part of its passage the change spans. |

## Usage

```astro
<ScrollScrub y={80} scale={0.9} opacity={0}>
  <img src="/screenshot.png" alt="" />
</ScrollScrub>
<ScrollScrub rotate={-6} range="entry 0% entry 100%">tilted in</ScrollScrub>
<ScrollScrub rotateX={24} scale={0.94} range="entry 0% cover 55%">
  <BrowserFrame url="https://example.com" src={shot} alt="" />
</ScrollScrub>
```

## Reduced motion

No scrubbing; the element shows its end state.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Default range entry 0% → cover 40%: the change completes while the element is still in the lower half of the viewport, so it is settled by the time the reader reaches it.
- Linear timing; a scrubbed animation with easing feels detached from the hand.
- Reveal is a trigger, this is a binding. Use Reveal for entrances; use ScrollScrub when the motion must track the scroll.

## Replaces

- GSAP ScrollTrigger scrub: true
- framer-motion useScroll + useTransform

## Source

```astro
---
/**
 * ScrollScrub — bind any transform to scroll position, zero JS.
 *
 * Reveal answers "appear when seen"; this answers "move exactly as I
 * scroll": translate, scale, rotate and opacity go from the given start
 * values to neutral across a range of the element's own view timeline.
 * Without scroll timelines the element shows its end state.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Start x offset in px. */
  x?: number;
  /** Start y offset in px. */
  y?: number;
  /** Start scale. */
  scale?: number;
  /** Start rotation in degrees. */
  rotate?: number;
  /** Start opacity. */
  opacity?: number;
  /** Start tilt on the X axis in degrees — the "container scroll" look. Adds perspective to the element itself. */
  rotateX?: number;
  /** Perspective in px, used with rotateX. */
  perspective?: number;
  /** animation-range of the element's view timeline. */
  range?: string;
}

const { as: Tag = 'div', x = 0, y = 0, scale = 1, rotate = 0, opacity = 1, rotateX, perspective = 1200, range = 'entry 0% cover 40%', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-ss-x:${x}px`, `--ma-ss-y:${y}px`, `--ma-ss-scale:${scale}`, `--ma-ss-rotate:${rotate}deg`, `--ma-ss-opacity:${opacity}`, `--ma-ss-range:${range}`, rotateX !== undefined && `--ma-ss-rx:${rotateX}deg`, rotateX !== undefined && `--ma-ss-p:${perspective}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-scrub', className]} data-tilt={rotateX !== undefined ? '' : undefined} style={inline} {...rest}>
  <slot />
</Tag>

<style is:global>
  @layer components {
    @supports (animation-timeline: view()) {
      :where(.ma-scrub) {
        will-change: translate, scale, rotate, opacity;
        animation: ma-scrub linear both;
        animation-range: var(--ma-ss-range, entry 0% cover 40%);
      }
      :where(html) :where(.ma-scrub) {
        animation-timeline: view();
      }
      /* rotateX: the 3D pair on the same element — perspective() and rotateX() in one transform, no wrapper */
      :where(.ma-scrub[data-tilt]) {
        animation-name: ma-scrub-tilt;
        will-change: translate, scale, rotate, opacity, transform;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-scrub) {
        animation: none;
        will-change: auto;
      }
    }
  }

  @keyframes ma-scrub-tilt {
    from {
      translate: var(--ma-ss-x, 0px) var(--ma-ss-y, 0px);
      scale: var(--ma-ss-scale, 1);
      rotate: var(--ma-ss-rotate, 0deg);
      opacity: var(--ma-ss-opacity, 1);
      transform: perspective(var(--ma-ss-p, 1200px)) rotateX(var(--ma-ss-rx, 0deg));
    }
    to {
      translate: 0 0;
      scale: 1;
      rotate: 0deg;
      opacity: 1;
      transform: perspective(var(--ma-ss-p, 1200px)) rotateX(0deg);
    }
  }
  @keyframes ma-scrub {
    from {
      translate: var(--ma-ss-x, 0px) var(--ma-ss-y, 0px);
      scale: var(--ma-ss-scale, 1);
      rotate: var(--ma-ss-rotate, 0deg);
      opacity: var(--ma-ss-opacity, 1);
    }
    to {
      translate: 0 0;
      scale: 1;
      rotate: 0deg;
      opacity: 1;
    }
  }
</style>

```
