Skip to content

stack

aa-stack turns a list of [aa-stack-card] elements into a stacking-cards section: each card sticks at the top of the viewport while the page scrolls past, and the next card overlays the previous one. Cards stay locked using CSS position: sticky — GSAP only owns the entry / exit transforms and the per-card events that drive content animations inside.

<section aa-stack aa-stack-in="fade" aa-stack-lock="bounce" aa-stack-out="perspective">
<article aa-stack-card>
<h2 aa-animate="fade-up">Card 1</h2>
<p aa-animate="fade-up" aa-delay="0.15">Body fades up when this card enters.</p>
</article>
<article aa-stack-card></article>
<article aa-stack-card></article>
</section>

Required GSAP plugins: gsap, ScrollTrigger.

AttributeWhat it marks
aa-stackStack root. A bare marker — you don't need a value; the lifecycle is configured through aa-stack-in / -lock / -out. Pass none (typically via the | shorthand) to disable the JS at a breakpoint.
aa-stack-cardEach card. The shipped stylesheet applies position: sticky; top: var(--aa-stack-top, 25vh) at zero specificity (:where()) so any author class that sets top or position wins without !important.

The sticky positioning is CSS-only and unconditional — it applies to every [aa-stack-card] regardless of breakpoint or aa-stack value. aa-stack="none" only disables the GSAP layer (in/out tweens, lock animation, and the per-card events); the cards stay sticky. To make cards flow normally at a breakpoint, revert position yourself in CSS (or, in Webflow, set the card's Position back to Relative on that breakpoint). See Responsive control.

The shipped stylesheet applies position: sticky; top: var(--aa-stack-top, 25vh) to every [aa-stack-card]. Three ways to change the offset:

  • Plain CSS.my-card { top: 6rem } wins by specificity.
  • CSS variable, in CSS.my-card { --aa-stack-top: 6rem }.
  • CSS variable, inline<article aa-stack-card style="--aa-stack-top: 6rem">.

The JS reads getComputedStyle(cards[0]).top so all three paths line up with the in/out tween end positions.

Three space-separated attributes describe the full lifecycle of every card. Flags inside each attribute compose, so aa-stack-in="fade scale" does both.

Runs from aa-scroll-start (default top 85%) until the card hits its sticky lock. Scrubbed against scroll progress.

FlagEffect
fadeopacity: 0 → 1, completing at the midpoint of the entry travel — the card is fully opaque before it locks, while scale / rotation keep settling to lock.
scalescale: (1 - 0.2 × intensity) → 1 (e.g. 0.8 → 1 at aa-intensity="1")
rotateCards arrive tilted and settle flat: centred fan (0°, -5°, +5°, -5°, +5°, …) × intensity → . First card stays flat; later cards alternate sides.
rotate-cwIncremental clockwise from-state, settles flat: (0°, +1°, +2°, +3°, …) × intensity → .
rotate-ccwMirror of rotate-cw: (0°, -1°, -2°, -3°, …) × intensity → .
tiltInverse of rotate — arrives flat, locks into the centred fan (0°, -5°, +5°, -5°, +5°, …) × intensity.
tilt-cwInverse of rotate-cw — arrives flat, locks into the clockwise ramp (0°, +1°, +2°, +3°, …) × intensity.
tilt-ccwMirror of tilt-cw: (0°, -1°, -2°, -3°, …) × intensity.
noneNo entry animation; cards appear in their natural sticky position.

The six rotation-touching flags all control the same property and are mutually exclusive. If multiple are listed, tilt* wins over rotate* (the lock-state value overrides the settle-flat reset), and within each family *-cw/*-ccw wins over the plain flag.

Plays once when the card reaches its sticky lock position. Non-scrubbed. Skipped under (prefers-reduced-motion: reduce).

FlagEffect
bounceOsmo-style stretch + elastic settle (scaleX/Y pulse).
pulseQuick scale: 1 → 1.03 → 1 with a back-out ease.
noneNo lock animation; the card-active event still fires.

Runs over the next card's entry window — i.e. as the next card scrolls toward its lock, the current card animates out. Scrubbed.

FlagEffect
fadeopacity → 0
scalescale → (1 - 0.15 × intensity) (e.g. 0.85)
perspective3D tilt-back: rotationX: 10 × intensity, slight scale-down, y lift of 7% × intensity of card height.
blurfilter: blur(8 × intensity px) + y lift of 3.5% × intensity of card height.
leftSlide x: −4 × intensity rem while fading out.
rightSlide x: 4 × intensity rem while fading out.
noneCards hold their settled state; the next card overlays them naturally.

The last card normally has no exit animation — nothing overlays it. The one exception is perspective: there the last card rises by half that lift (3.5% × intensity of its height) at the end of the stack so it covers the receded card behind it instead of leaving a gap. (Half, because the receding card also scales down — its top edge drops back, so its visible peek is less than the full lift. The feature adds that distance as the last card's bottom margin to give it the scroll runway it needs.)

The lift is a percentage of the card's own height (translateY(%)), not a fixed rem. A fixed lift gets swallowed on tall cards — the scale: 0.92 pulls the top edge down by ~4% of card height, which on a large screen (tall cards) outgrows a fixed lift and hides the card entirely behind the next one. A height-relative lift keeps the peek proportional across screen sizes.

AttributeDefaultNotes
aa-intensity(init intensity, 1)Multiplies preset intensities (rotation angles, scale offsets, blur, translate distance).
aa-scroll-start(init scrollStart)When card-active fires for each card (drives the inner aa-animate children only). Standard GSAP syntax (top 85%, center 50%). The in / out visual tweens always scrub the full entry-to-lock range regardless of this value.

aa-scrub is intentionally not honoured by the stack feature. The cards themselves move with the scroll instantly via CSS position: sticky, so smoothing only the transform layer (rotate / scale / blur) would desync from the (instant) card position. Stack tweens are always direct scroll-locked.

aa-animate elements inside an [aa-stack-card] are automatically driven by card activation. The stack feature emits card-active when a card crosses aa-scroll-start and card-inactive when it fully leaves the viewport (matching the aa-again reset point used by scroll-triggered animations elsewhere). The closest container ancestor is the inferred trigger, so you don't write aa-trigger yourself.

<article aa-stack-card>
<h2 aa-animate="fade-up">Headline</h2> <!-- inferred: event:card-active -->
<p aa-animate="fade-up" aa-delay="0.15">Body</p>
</article>

This is important: the per-card content animations fire when each card enters the viewport, not when it locks. By the time the card visually locks at the top, its content has already animated in. Per-child aa-delay staggers compose as usual.

Set aa-trigger="scroll" on an inner element to opt out of inference and use its own ScrollTrigger.

aa-stack takes the standard responsive syntax — the | shorthand (splits at md, 768px) or the -sm / -md / -lg / -xl suffixes. Because the value is normally empty (the marker just enables the feature), none is the only token you'll pass here, on the breakpoint where you want the JS off.

<!-- Active on tablet + desktop (≥ md / 768px), off below.
Left of the | applies at md and up; right applies below it. -->
<section aa-stack="|none" aa-stack-in="fade scale" aa-stack-out="perspective"></section>
<!-- Suffix form of the same thing: off in the base range, on from md up. -->
<section aa-stack="none" aa-stack-md="" aa-stack-in="fade scale"></section>
<!-- Different lifecycle presets per breakpoint -->
<section aa-stack aa-stack-in="fade scale" aa-stack-in-sm="fade"></section>

When aa-stack="none" is active, the feature skips initialization at that breakpoint — no in/out tweens, no lock animation, no card-active / card-inactive events. The [aa-stack-card] elements stay sticky regardless, because that comes from the (unconditional, zero-specificity) CSS rule, not from the JS. If you want the cards to flow normally below a breakpoint, revert their position there yourself:

@media (max-width: 767px) {
[aa-stack-card] { position: relative; top: auto; }
}

In Webflow, set the card's Position back to Relative (or Static) on the Tablet/Mobile breakpoint — :where() is zero-specificity, so the breakpoint setting wins without !important. Pair that with aa-stack="|none" so both layers turn off together.

In-card aa-animate elements adapt automatically: while the stack is active they're driven by card-active, but in a range where aa-stack resolves to none the feature never emits that event, so trigger inference skips it and the element falls back to scroll. You don't write aa-trigger for either case.

Live demo — disabled below md (scroll fallback)

Section titled “Live demo — disabled below md (scroll fallback)”

aa-stack="|none" keeps the stack on tablet + desktop and turns the JS off below md (768px). The CSS reverts the cards to normal flow on mobile, and the in-card headlines fall back to scroll-triggered playback. Resize across 768px to compare — above it the content fires on card activation, below it on scroll.

Tablet + desktop: stacked

Above 768px these cards stick and the content fires on card activation.

Below md: plain scroll

On mobile the stack JS is off, the cards flow normally, and these lines fade up on scroll instead.

One attribute, both modes

The same markup drives the sticky stack above 768px and a plain scroll list below it.

Live demo — rotate entrance + perspective exit

Section titled “Live demo — rotate entrance + perspective exit”

aa-stack-in="rotate" on entry, aa-stack-out="perspective" on exit. Cards lock at 40vh (vertically centered). The in/out tweens always scrub across the full entry-to-lock range; aa-scroll-start (default top 85%) governs only when the inner aa-animate children fire.

First card

Each card sticks at 40vh while the page keeps scrolling. The next card overlays this one as it enters.

Second card

Same animations, scoped to this card. The fade-up replays on every entry and reverses on full exit.

Third card

With perspective the last card rises a touch at the end to cover the receded card behind it.

Scroll-triggered heading below the sticky stack — verifies regular scroll triggers fire correctly once you scroll past the sticky section.

No aa-stack-in / aa-stack-out — cards just sit naturally as sticky stack until they reach their lock point, where the Osmo-style bounce pulse fires. Inner content fades up on activation.

Bounce on lock

No entry tween. The card slides up to lock under its own weight.

Elastic settle

A quick scaleX/Y stretch and an elastic snap back when the card hits its sticky lock position.

No exit transform

The natural sticky overlay does all the work. The previous card simply sits behind, untouched.

Live demo — tilt-cw (Osmo splay-at-lock)

Section titled “Live demo — tilt-cw (Osmo splay-at-lock)”

aa-stack-in="tilt-cw" makes each card pick up a touch more clockwise rotation than the previous one as it approaches lock. The first card stays flat and the ramp grows by 1° × aa-intensity per card. Swap to tilt-ccw for the mirror, or tilt for a centred fan with bigger steps.

0° at lock

First card stays flat — the ramp starts at zero. Pattern: 0°, +1°, +2°, +3°, …

+1° at lock

Second card picks up one degree of clockwise rotation as it scrubs toward lock.

+2° at lock

Third card continues the ramp at +2°. Crank up aa-intensity on the root to scale the whole splay.

Live demo — scale in, pulse on lock, blur + fade out

Section titled “Live demo — scale in, pulse on lock, blur + fade out”

All three lifecycle slots active. Cards scale up from 0.8 as they enter, fire a quick scale: 1 → 1.03 → 1 pulse on lock, then blur and fade out as the next card overlays them.

Scale on entry

Card scrubs from scale: 0.8 to 1 as it approaches lock.

Pulse on lock

A discrete scale: 1 → 1.03 → 1 back-out beat fires the moment the card locks.

Blur + fade out

As the next card enters, each previous card scrubs to filter: blur(8px) and opacity: 0.