{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stack-fly-cards","type":"registry:component","title":"Stack Fly-Out Cards","description":"Cards rise into a peeking stack, then fly off one by one as you scroll.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/stack-fly-cards/stack-fly-cards.tsx","type":"registry:component","target":"components/dimaac/stack-fly-cards.tsx","content":"\"use client\";\n\nimport { useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from \"react\";\nimport { gsap } from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A deck of cards in a pinned section. As you scroll, the cards rise into a stack where each one\n// peeks out under the one in front, then the front card flies off the top, the rest move up a\n// place, and so on until only the last card is left. One scrubbed timeline does all of it, so\n// scrolling back plays it in reverse.\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(useGSAP, ScrollTrigger);\n\nexport type StackFlyCard = {\n  /** The card's photo. */\n  src: string;\n  /** Describes the photo for screen readers. Empty means it's decoration. */\n  alt?: string;\n  /** The big serif title. */\n  title: string;\n  /** A short line under the title. */\n  lede?: string;\n  /** The paragraph near the bottom of the card. */\n  body: string;\n  /** Where the card's button goes. No href, no button. */\n  href?: string;\n  /** The button's label. */\n  cta?: string;\n  /** The card's colour. Defaults to a palette of five soft tones. */\n  background?: string;\n  /** The card's text colour. */\n  foreground?: string;\n};\n\nexport type StackFlyCardsProps = {\n  /** The cards, front card first. Three to six read best. */\n  cards: StackFlyCard[];\n  /** How far each card in the stack peeks out under the one in front, in pixels. */\n  peek?: number;\n  /** How much smaller each card in the stack is than the one in front (0.05 is 5%). */\n  scaleStep?: number;\n  /** How far a card turns as it flies off, in degrees. */\n  flyRotate?: number;\n  /** How long the pinned section lasts: screens of scrolling per card. */\n  scrollLength?: number;\n  /** How long the cards take to catch up with the scrollbar, in seconds. 0 follows it exactly. */\n  smoothing?: number;\n  /** The scrolling element (an element or a selector). Leave it out to use the page. */\n  scroller?: HTMLElement | string | null;\n  /** The section's background. The cards' shadows look best on a dark one. */\n  background?: string;\n  /** The colour of the square behind each button's arrow. */\n  accent?: string;\n  /** The font of the card titles and numbers. Load it yourself (the demo loads Instrument Serif). */\n  titleFont?: string;\n  /** Content above the deck, like a heading. It sits over the cards as they fly past. */\n  children?: ReactNode;\n  /** Extra classes for the heading's box, e.g. \"mix-blend-difference\" so it inverts over the cards. */\n  headerClassName?: string;\n  /** Extra classes for the section. It's one screen tall and pins while the cards move. */\n  className?: string;\n};\n\n// The five tones from the original video, used in turn when a card has no background of its own\nconst tones = [\"#d6ef4a\", \"#f4f4f2\", \"#6db5a8\", \"#e8d5c4\", \"#c8b8e8\"];\n\n// prefers-reduced-motion, live\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\nfunction resolveScroller(scroller: StackFlyCardsProps[\"scroller\"]): HTMLElement | null {\n  if (!scroller) return null;\n  if (typeof scroller === \"string\") {\n    const element = document.querySelector<HTMLElement>(scroller);\n    // Pinning against the page when a scroller was asked for looks broken in a confusing way, so say so\n    if (!element && typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\") console.warn(\"StackFlyCards: scroller not found\", scroller);\n    return element;\n  }\n  return scroller;\n}\n\nexport function StackFlyCards({\n  cards,\n  peek = 42,\n  scaleStep = 0.045,\n  flyRotate = 25,\n  scrollLength = 1,\n  smoothing = 0.6,\n  scroller = null,\n  background = \"#121212\",\n  accent = \"#d6ef4a\",\n  titleFont = '\"Instrument Serif\", \"Times New Roman\", Times, serif',\n  children,\n  headerClassName = \"\",\n  className = \"\",\n}: StackFlyCardsProps) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const cardRefs = useRef<(HTMLElement | null)[]>([]);\n  // Where the scroller was, so rebuilding the timeline (a control changed) doesn't jump the page\n  const lastScroll = useRef(0);\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n\n  useGSAP(\n    () => {\n      const section = sectionRef.current;\n      const deck = cardRefs.current.slice(0, cards.length).filter((card): card is HTMLElement => card !== null);\n      if (!section || deck.length === 0 || reducedMotion) return;\n\n      const scrollerElement = resolveScroller(scroller);\n      const viewportHeight = () => (scrollerElement ? scrollerElement.clientHeight : window.innerHeight);\n      // Where card i sits in the stack: lower down and a little smaller for every card in front of it\n      const pose = (i: number) => ({ y: i * peek, scale: 1 - i * scaleStep });\n\n      // In a scrolling element the section is as tall as that element, not the window\n      const fitHeight = () => {\n        if (scrollerElement) section.style.minHeight = `${scrollerElement.clientHeight}px`;\n      };\n      fitHeight();\n      ScrollTrigger.addEventListener(\"refreshInit\", fitHeight);\n\n      // Scaling from the top edge keeps each card's top where the peek puts it\n      gsap.set(deck, { zIndex: (i: number) => deck.length - i, transformOrigin: \"50% 0%\", force3D: true });\n\n      const timeline = gsap.timeline({\n        scrollTrigger: {\n          trigger: section,\n          scroller: scrollerElement ?? undefined,\n          start: \"top top\",\n          end: () => `+=${deck.length * viewportHeight() * scrollLength}`,\n          pin: true,\n          scrub: smoothing > 0 ? smoothing : true,\n          invalidateOnRefresh: true,\n        },\n      });\n\n      // 1. The cards rise from below the fold into the stack, one a moment after another\n      deck.forEach((card, i) => {\n        timeline.fromTo(\n          card,\n          { y: () => viewportHeight() * 0.72 + i * peek, scale: pose(i).scale * 0.9, rotation: 0 },\n          { ...pose(i), ease: \"power3.out\", duration: 1.35 },\n          i * 0.06,\n        );\n      });\n\n      // A short rest with the whole stack in view\n      timeline.to({}, { duration: 0.35 });\n      const flyAt = timeline.duration();\n\n      // 2. The front card flies off the top and everything behind it moves up a place.\n      //    The last card stays: it's where the section ends.\n      deck.slice(0, -1).forEach((card, i) => {\n        const behind = deck.slice(i + 1);\n        timeline.to(card, { y: () => -viewportHeight() * 1.15, rotation: -flyRotate, scale: 0.94, ease: \"none\", duration: 1 }, flyAt + i);\n        timeline.to(behind, { y: (index: number) => pose(index).y, scale: (index: number) => pose(index).scale, ease: \"none\", duration: 1 }, flyAt + i);\n      });\n\n      // A short rest on the last card before the section scrolls away\n      timeline.to({}, { duration: 0.4 });\n\n      const trigger = timeline.scrollTrigger;\n      if (!trigger) return;\n\n      // On a rebuild (a prop changed), taking the old pin out made the page shorter for a moment\n      // and the browser pulled the scroll back. Measure the new pin now and put the scroll back.\n      if (lastScroll.current > 0) {\n        trigger.refresh();\n        if (Math.abs(trigger.scroll() - lastScroll.current) > 1) trigger.scroll(lastScroll.current);\n      }\n\n      const scrollTarget: HTMLElement | Window = scrollerElement ?? window;\n      const onScroll = () => {\n        lastScroll.current = trigger.scroll();\n      };\n      scrollTarget.addEventListener(\"scroll\", onScroll, { passive: true });\n\n      // Tabbing to a button on a card that's behind (or already gone) scrolls to where it's in front\n      const onFocusIn = (event: FocusEvent) => {\n        const index = deck.findIndex((card) => card.contains(event.target as Node));\n        if (index < 0) return;\n        const progress = Math.min(1, (flyAt + index) / timeline.duration());\n        trigger.scroll(trigger.start + (trigger.end - trigger.start) * progress);\n      };\n      section.addEventListener(\"focusin\", onFocusIn);\n\n      return () => {\n        ScrollTrigger.removeEventListener(\"refreshInit\", fitHeight);\n        scrollTarget.removeEventListener(\"scroll\", onScroll);\n        section.removeEventListener(\"focusin\", onFocusIn);\n        section.style.minHeight = \"\";\n      };\n    },\n    { scope: sectionRef, dependencies: [cards.length, peek, scaleStep, flyRotate, scrollLength, smoothing, scroller, reducedMotion], revertOnUpdate: true },\n  );\n\n  return (\n    <section\n      ref={sectionRef}\n      className={`relative flex w-full flex-col items-center overflow-hidden px-5 pt-[clamp(1.75rem,4vh,2.75rem)] pb-[clamp(2rem,5vh,3.5rem)] ${reducedMotion ? \"\" : \"min-h-[100dvh]\"} ${className}`}\n      style={{ background }}\n    >\n      {children ? <div className={`pointer-events-none relative z-20 mt-8 mb-[clamp(1.5rem,5vh,3.5rem)] shrink-0 text-center ${headerClassName}`}>{children}</div> : null}\n\n      <div className=\"relative my-auto w-full max-w-[1100px]\">\n        {/* Reduced motion: no pin, no flight. The cards are a plain column you scroll past. */}\n        <div className={reducedMotion ? \"flex flex-col gap-5\" : \"relative h-[min(68vh,580px)] md:h-[min(62vh,540px)]\"}>\n          {cards.map((card, index) => (\n            <article\n              key={`${index}-${card.title}`}\n              ref={(element) => {\n                cardRefs.current[index] = element;\n              }}\n              className={`${reducedMotion ? \"relative h-[min(80vh,580px)] md:h-[min(62vh,540px)]\" : \"absolute inset-0 will-change-transform\"} flex flex-col gap-4 rounded-[3px] p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)] md:grid md:grid-cols-[1.15fr_0.85fr] md:gap-[clamp(1rem,3vw,2.5rem)] md:p-[clamp(1.25rem,3vw,2.25rem)]`}\n              style={\n                {\n                  background: card.background ?? tones[index % tones.length],\n                  color: card.foreground ?? \"#141414\",\n                  // The button's focus ring uses the card's text colour, so it shows on light and dark cards alike\n                  \"--card-fg\": card.foreground ?? \"#141414\",\n                  zIndex: reducedMotion ? undefined : cards.length - index,\n                } as CSSProperties & Record<\"--card-fg\", string>\n              }\n            >\n              <div className=\"order-2 flex min-h-0 min-w-0 flex-1 flex-col md:order-none\">\n                <div className=\"mb-[0.65rem] flex items-start justify-between gap-4\">\n                  <h3 className=\"text-[clamp(2rem,4.5vw,3.4rem)] leading-none font-normal tracking-[-0.02em]\" style={{ fontFamily: titleFont }}>\n                    {card.title}\n                  </h3>\n                  <span aria-hidden=\"true\" className=\"shrink-0 text-[clamp(1.5rem,3vw,2.25rem)] leading-none opacity-35\" style={{ fontFamily: titleFont }}>\n                    {String(index + 1).padStart(2, \"0\")}\n                  </span>\n                </div>\n                {card.lede ? <p className=\"max-w-[28ch] text-[clamp(0.85rem,1.4vw,1rem)] leading-[1.45] opacity-75\">{card.lede}</p> : null}\n\n                <div className=\"mt-auto pt-4\">\n                  <p className=\"max-w-[36ch] text-[clamp(0.8rem,1.2vw,0.92rem)] leading-[1.55] opacity-85\">{card.body}</p>\n                  {card.href ? (\n                    <a\n                      href={card.href}\n                      className=\"mt-4 inline-flex items-center gap-3 bg-[#111] py-[0.55rem] pr-[0.55rem] pl-4 text-[0.85rem] text-white no-underline transition-transform duration-200 hover:-translate-y-px focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--card-fg)] md:mt-5\"\n                    >\n                      {card.cta ?? \"Discover our approach\"}\n                      <span aria-hidden=\"true\" className=\"grid size-7 place-items-center text-[#111]\" style={{ background: accent }}>\n                        <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\">\n                          <path d=\"M2 7h10M8 3l4 4-4 4\" />\n                        </svg>\n                      </span>\n                    </a>\n                  ) : null}\n                </div>\n              </div>\n\n              <div className=\"order-1 flex h-[36%] min-h-[130px] shrink-0 justify-end md:order-none md:h-auto md:min-h-0\">\n                <div className=\"h-full w-full overflow-hidden bg-[#1a1a1a] shadow-[0_10px_28px_rgba(0,0,0,0.14)] md:w-[min(100%,260px)]\">\n                  <img src={card.src} alt={card.alt ?? \"\"} className=\"block h-full w-full object-cover\" draggable={false} decoding=\"async\" />\n                </div>\n              </div>\n            </article>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"}],"categories":["scroll","gsap","scrolltrigger","pin","cards","stack","images"],"docs":"import { StackFlyCards } from \"@/components/dimaac/stack-fly-cards\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/stack-fly-cards","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/stack-fly-cards","credits":[{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Portrait (lime blazer)","source":"Lummi","licence":"Lummi License"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn","what":"Fashion photo (grey suit)","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/minimalist-rustic-interior-qppyl","what":"Interior","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Fashion portrait (orange visor)","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa","what":"Architecture (pastel rooftop)","source":"Lummi","licence":"Lummi License"}]}}