Parallax
ScrollAn element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.
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. Zero JavaScript: sticky positioning plus each card's scale bound to the next card's view timeline. Up to eight cards.
scroll inside
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.7 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 116 · not Firefox · Safari 26; needs scroll-timeline, timeline-scope. Elsewhere: cards still stack with sticky positioning, without the shrink (Firefox, Chrome 115).
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 CardStack from '@moonarc/core/CardStack';
---
<CardStack top="8vh" peek="1.25rem">
<section class="rounded-2xl bg-surface p-8">One</section>
<section class="rounded-2xl bg-surface p-8">Two</section>
<section class="rounded-2xl bg-surface p-8">Three</section>
</CardStack>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/card-stack.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.
---
/**
* CardStack — cards stick as you scroll and each one shrinks back as the
* next slides over it. Zero JS: every child is sticky, and each card's
* scale is bound to the *next* card's view timeline through timeline-scope,
* so the shrink is exactly in step with the cover. Up to eight cards.
* Without scroll timelines, or without timeline-scope (Chrome 115), the
* cards still stack; they just do not shrink.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Sticky offset from the top of the viewport, any CSS length. */
top?: string;
/** Extra offset per card so the stack's edges peek out. */
peek?: string;
/** Scale a covered card shrinks to. */
scale?: number;
/** Minimum height of each card's slot, so there is something to scroll through. */
height?: string;
}
const { top = '10vh', peek = '1rem', scale = 0.92, height = '70vh', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-stack-top:${top}`, `--ma-stack-peek:${peek}`, `--ma-stack-scale:${scale}`, `--ma-stack-h:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-stack', className]} style={vars} {...rest}>
<slot />
</div>
<style is:global>
@layer components {
:where(.ma-stack) {
display: grid;
gap: var(--ma-stack-peek, 1rem);
timeline-scope: --ma-c1, --ma-c2, --ma-c3, --ma-c4, --ma-c5, --ma-c6, --ma-c7, --ma-c8;
}
:where(.ma-stack > *) {
position: sticky;
top: calc(var(--ma-stack-top, 10vh) + var(--ma-stack-i, 0) * var(--ma-stack-peek, 1rem));
min-height: var(--ma-stack-h, 70vh);
transform-origin: 50% 0;
}
:where(.ma-stack > :nth-child(1)) { --ma-stack-i: 0; view-timeline-name: --ma-c1; }
:where(.ma-stack > :nth-child(2)) { --ma-stack-i: 1; view-timeline-name: --ma-c2; }
:where(.ma-stack > :nth-child(3)) { --ma-stack-i: 2; view-timeline-name: --ma-c3; }
:where(.ma-stack > :nth-child(4)) { --ma-stack-i: 3; view-timeline-name: --ma-c4; }
:where(.ma-stack > :nth-child(5)) { --ma-stack-i: 4; view-timeline-name: --ma-c5; }
:where(.ma-stack > :nth-child(6)) { --ma-stack-i: 5; view-timeline-name: --ma-c6; }
:where(.ma-stack > :nth-child(7)) { --ma-stack-i: 6; view-timeline-name: --ma-c7; }
:where(.ma-stack > :nth-child(8)) { --ma-stack-i: 7; view-timeline-name: --ma-c8; }
/* timeline-scope arrived a release after view timelines (Chrome 116): without it the names never reach a sibling */
@supports (animation-timeline: view()) and (timeline-scope: none) {
:where(.ma-stack > :not(:last-child)) {
animation: ma-stack linear both;
animation-range: entry 0% entry 100%;
}
:where(.ma-stack > :nth-child(1):not(:last-child)) { animation-timeline: --ma-c2; }
:where(.ma-stack > :nth-child(2):not(:last-child)) { animation-timeline: --ma-c3; }
:where(.ma-stack > :nth-child(3):not(:last-child)) { animation-timeline: --ma-c4; }
:where(.ma-stack > :nth-child(4):not(:last-child)) { animation-timeline: --ma-c5; }
:where(.ma-stack > :nth-child(5):not(:last-child)) { animation-timeline: --ma-c6; }
:where(.ma-stack > :nth-child(6):not(:last-child)) { animation-timeline: --ma-c7; }
:where(.ma-stack > :nth-child(7):not(:last-child)) { animation-timeline: --ma-c8; }
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-stack > *) {
animation: none;
}
}
}
@keyframes ma-stack {
to {
scale: var(--ma-stack-scale, 0.92);
filter: brightness(0.85);
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
top | string | '10vh' | Sticky offset from the top of the viewport. |
peek | string | '1rem' | Extra offset per card so the stacked edges show. |
scale | number | 0.92 | Scale a covered card shrinks to. |
height | string | '70vh' | Minimum height of each card; it is what you scroll through. |
Cards stack without shrinking or dimming.
ClientRouterCSS-only; nothing to rebind.
Replaces: ScrollStack (React Bits) · StickyScroll (Aceternity) · GSAP pinned card stacks. See the migration table.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown