Skip to content

Components / Scroll

Scroll Scrub

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.

Live demo

scrubbed

Bound to the scroll.

rotateX=24

Tilts, then stands up.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 115 · not Firefox · Safari 26; needs scroll-timeline. Elsewhere: the end state, static (Firefox).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import ScrollScrub from '@moonarc/core/ScrollScrub';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/scroll-scrub.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

ScrollScrub.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>

Props

PropTypeDefaultDescription
asHTMLTag'div'Element to render.
xnumber0Start x offset in px.
ynumber0Start y offset in px.
scalenumber1Start scale.
rotatenumber0Start rotation in degrees.
opacitynumber1Start opacity.
rotateXnumbernoneStart 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.
perspectivenumber1200Perspective in px, with rotateX.
rangestring'entry 0% cover 40%'animation-range on the element's view timeline: which part of its passage the change spans.

Reduced motion

No scrubbing; the element shows its end state.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: GSAP ScrollTrigger scrub: true · framer-motion useScroll + useTransform. See the migration table.

Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.

Chrome 116 · not Firefox · Safari 26scroll

Parallax

Scroll

An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.

Chrome 115 · not Firefox · Safari 26scroll

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown