Card Stack
ScrollCards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.
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.
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
Chrome 115 · not Firefox · Safari 26; needs scroll-timeline. Elsewhere: the end state, static (Firefox).
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/scroll-scrub.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
No scrubbing; the element shows its end state.
ClientRouterCSS-only; nothing to rebind.
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.
An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown