component

Sticky Scroll Cards

Scroll-pinned hero where a front card flips to reveal a stack of back cards, dismissed one at a time as you keep scrolling.

Made by Axyl

Installation

File Structure

sticky-scroll-cards.tsx
use-prefers-reduced-motion.tsx
scroll-trigger-utils.tsx

Usage

Basic

import { StickyScrollCards } from "@/components/sora-ui/effects/sticky-scroll-cards";

const front = {
  title: "First Frame",
  description: "A single moment, held in place before everything begins to move.",
};

const cards = [
  { title: "Final Hold", description: "Everything settles into place." },
  { title: "Layered Time", description: "Moments stack and reveal themselves." },
  { title: "Weight & Flow", description: "Elements ease in and out with balance." },
  { title: "Soft Motion", description: "Subtle shifts build a quiet rhythm." },
];

export default function Section() {
  return (
    <StickyScrollCards
      cards={cards}
      front={front}
      headline="Scroll to pin, flip, and let go"
    />
  );
}

Codegrid preset (variant="studio")

Cream canvas, condensed display type, and the original Codegrid "MaximaTherapy" palette cycled across back cards:

<StickyScrollCards
  cards={cards}
  front={front}
  headline="Scroll to pin, flip, and let go"
  variant="studio"
/>

variant="minimal" (default) uses theme tokens (bg-background/bg-foreground). Override any slot via the classNames prop.

Custom class map (advanced)

Import STICKY_SCROLL_CARDS_STUDIO_CLASSES to extend the studio preset:

import {
  STICKY_SCROLL_CARDS_STUDIO_CLASSES as studio,
  StickyScrollCards,
} from "@/components/sora-ui/effects/sticky-scroll-cards";

<StickyScrollCards
  cards={cards}
  classNames={{ front: cn(studio.front, "shadow-2xl") }}
  front={front}
  headline={headline}
  variant="studio"
/>;

Docs preview

embedded = CSS sticky scroll track (no GSAP pin) sized for a preview panel. Pass scroller when inside a scroll panel.

<StickyScrollCards
  cards={cards}
  containerQuery
  embedded
  front={front}
  headline={headline}
  scroller={viewport}
  variant="studio"
/>

Timing

Total scroll distance is dismissStart + cards.length × cardDismissDuration (svh). Back cards dismiss one at a time in reverse order — the last card in the array leaves first.

<StickyScrollCards
  cards={cards}
  front={front}
  headline={headline}
  timing={{
    cardsEnterEnd: 500,
    cardFlipTrigger: 1000,
    dismissStart: 1500,
    cardDismissDuration: 500,
    flipTiltAngles: [-10, -20, -5, 10],
    dismissTiltAngles: [-50, -60, -45, 50],
  }}
/>

flipTiltAngles/dismissTiltAngles are cycled by index — pass fewer values than cards.length and they repeat.

Styling

3D flip

The front card flips rotationY: 0 → 180 while back cards flip -180 → 0, so both live in the same stacking position with backface-visibility: hidden. If you override classNames.front/classNames.back, keep backface-visibility intact or the flip will show the reverse face mid-turn.

Card colors

In variant="studio", back card colors come from a cycled palette applied as inline style, not Tailwind classes — classNames.back layers on top via className, it doesn't replace the color.

Class slots

className styles the root section. Pass any of these to classNames to override an inner layer:

  • headline — hero headline wrapper
  • cardsLayer — 3D perspective container
  • front / back — card surfaces
  • frontIcon / backIcon — icon circle
  • badge — front-only pill (e.g. "Start here")
  • title / description — card text
  • track — scroll track (embedded mode only)

Props

PropTypeDefault
front?
StickyScrollCardsItem
-
cards?
StickyScrollCardsItem[]
-
headline?
string
-
badgeLabel?
string
"Start here"
variant?
"minimal" | "studio"
minimal
embedded?
boolean
false
scroller?
Element | Window
-
refreshPriority?
number
-1
timing?
StickyScrollCardsTiming
-
className?
string
-
classNames?
StickyScrollCardsClassNames
-

StickyScrollCardsItem

PropTypeDefault
title?
string
-
description?
string
-
icon?
ReactNode
-

When to Use

Use StickyScrollCards for a hero moment that needs to hold attention through a reveal-then-dismiss sequence — feature highlights, a portfolio's opening act, or any section where a single held frame should fan out into a short, deliberate series before releasing the scroll. Fewer than 3 back cards makes the dismiss phase feel abrupt; 3–5 gives the rhythm room to land.

Accessibility

Card content stays in the DOM throughout the sequence. When prefers-reduced-motion: reduce is set, GSAP skips the animation and the section renders in its fully-dismissed end state immediately.

Credits

Inspired by Codegrid. Reimplemented for GSAP and vanilla JS.

Scroll to flip and dismiss cards