Skip to main content
← Back to Motion Recipes
advancedScroll

Sticky Cards

Stack large feature cards while scrolling using Angular, GSAP and ScrollTrigger.

standalone componentDestroyRef cleanupSSR-safe GSAP importtemplate refsScrollTriggerpinningscrubtimelinescale

A new type of Calendar

Plan, schedule and collaborate without the clutter of traditional calendar apps.

#1 in data privacy

Your data stays yours. End-to-end encryption and zero-knowledge architecture by default.

Designed for teams

Built from the ground up for async collaboration, stand-ups and sprint planning.

How it works

Each card is absolutely positioned inside a tall scrollable track. As the user scrolls, a single GSAP timeline with a ScrollTrigger maps scroll progress to the cards' yPercent, opacity and scale. Earlier cards scale down and fade while the next card slides up from below, producing a stacked, sticky feel without relying on CSS position: sticky.

The first card is visible at scroll position 0 so the demo never looks empty. As the user scrolls, the second and third cards slide up and take over the viewport.

The demo is self-contained: the ScrollTrigger uses the scroller option to watch the demo viewport instead of the page, so it works inside the showcase without page-level scroll.

The Angular way

  • GSAP and ScrollTrigger are lazy-loaded inside ngAfterViewInit and guarded by isPlatformBrowser so SSR never runs browser-only code.
  • gsap.context() scopes all selectors to the component host, making cleanup reliable.
  • DestroyRef tears down the context and kills every ScrollTrigger when the route changes.
  • prefers-reduced-motion bypasses the scroll-driven animation and renders the cards in a normal stacked layout.

Source code

<div
  class="sticky-cards-viewport relative h-[28rem] w-full overflow-y-auto rounded-2xl border border-border bg-card/50"
  [class.reduced-motion]="reducedMotion()"
>
  <div class="sticky-cards-track relative min-h-[400%] w-full">
    @for (card of cards; track card.id) {
      <div
        class="sticky-card absolute inset-0 flex items-center justify-center p-6 md:p-10"
        [class.theme-dark]="card.theme === 'dark'"
        [class.theme-light]="card.theme === 'light'"
        [class.theme-blue]="card.theme === 'blue'"
      >
        <div class="card-inner ...">
          <span class="icon">{{ card.icon }}</span>
          <h3>{{ card.title }}</h3>
          <p>{{ card.body }}</p>
          <button type="button">Learn more</button>
        </div>
      </div>
    }
  </div>
</div>

Implementation recipe

  1. Create a standalone Angular component with a tall scrollable container and a track with enough height.
  2. Add the card markup. Use absolute positioning and z-index so cards can stack visually.
  3. Lazy-load gsap and gsap/ScrollTrigger only in the browser.
  4. Use gsap.timeline({ scrollTrigger: { ... } }) with scrub: 1 and the local viewport as scroller.
  5. Make the first card visible at scroll 0, then animate subsequent cards in while scaling/fading the previous ones.
  6. Add a prefers-reduced-motion fallback that shows the cards stacked without animation.
  7. Clean up with gsap.context().revert() and kill all ScrollTriggers on destroy.

Accessibility notes

  • prefers-reduced-motion shows a normal stacked layout
  • all content is readable without scroll animation
  • keyboard focus order follows DOM order

Performance notes

  • animate transform and opacity only
  • kill ScrollTriggers on destroy to avoid leaks
  • use gsap.context to scope selectors

Common pitfalls

  • Letting ScrollTrigger attach to the window when the demo lives inside a scrollable container — always pass the correct scroller.
  • Forgetting to kill ScrollTriggers on route change, which causes errors and leaked listeners.
  • Animating top, left or layout properties instead of transform and opacity.
  • Starting every card with opacity: 0 so the demo looks empty before the user interacts.