{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-cards",
  "title": "Sticky Cards",
  "description": "A stack of same-size cards pinned on scroll. The front card lifts away, then the next, one by one.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "utils",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/cards/sticky-cards.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport gsap from \"gsap\"\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\"\nimport { useGSAP } from \"@gsap/react\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\ngsap.registerPlugin(useGSAP, ScrollTrigger)\n\nexport interface StickyCardItem {\n  id: string\n  tag: string\n  title: string\n  /** Image path, e.g. \"/sticky-cards/01.jpg\" from your public folder. */\n  image?: string\n  /** Card background. Defaults to the built-in palette, in order. */\n  color?: string\n  /** Card text color. Defaults to the palette's ink color. */\n  textColor?: string\n}\n\n/** purple · orange · red · sky · navy */\nexport const STICKY_CARD_PALETTE = [\n  { color: \"#3E2EA8\", textColor: \"#FFFFFF\" },\n  { color: \"#E2671E\", textColor: \"#FFFFFF\" },\n  { color: \"#C4302B\", textColor: \"#FFFFFF\" },\n  { color: \"#86BACB\", textColor: \"#10252B\" },\n  { color: \"#172B4D\", textColor: \"#FFFFFF\" },\n]\n\nexport interface StickyCardsProps {\n  cards: StickyCardItem[]\n  className?: string\n  /** Vertical offset (% of card height) between stacked cards. Default: 4 */\n  cardYOffset?: number\n  /** Scale reduction per card behind the front one. Default: 0.05 */\n  cardScaleStep?: number\n  /** Time (in the scroll timeline) between each card's exit starting. Default: 1.2 */\n  stepInterval?: number\n  /** Duration of each card's exit animation. Default: 1 */\n  stepDuration?: number\n  /** How many section-heights of scroll to reserve per card. Default: 1.8 */\n  scrollLengthPerCard?: number\n  /** Animate the last card away too. When false it stays on screen as the pin releases. Default: false */\n  exitLast?: boolean\n  /** Section background. Follows the theme by default. Default: \"var(--background)\" */\n  background?: string\n  /** Scroll container to track instead of the window (selector or element). */\n  scroller?: string | HTMLElement\n  /** Height of the pinned section (any CSS length). Default: \"100vh\" */\n  height?: string\n}\n\n// Condensed display face for titles. Load \"Barlow Condensed\" (or set --font-display) for the intended look.\nconst DISPLAY_FONT = `var(--font-display, \"Barlow Condensed\", \"Oswald\", \"Arial Narrow\", sans-serif)`\n\nfunction cardColors(card: StickyCardItem, index: number) {\n  const palette = STICKY_CARD_PALETTE[index % STICKY_CARD_PALETTE.length]\n  return {\n    backgroundColor: card.color ?? palette.color,\n    color: card.textColor ?? (card.color ? \"#FFFFFF\" : palette.textColor),\n  }\n}\n\n/** Image that falls back to a soft tinted panel when the file is missing. */\nfunction CardImage({ src, alt }: { src?: string; alt: string }) {\n  const ref = useRef<HTMLImageElement>(null)\n  const [failed, setFailed] = useState<string | null>(null)\n\n  // Catch images that failed before hydration attached onError\n  useEffect(() => {\n    const img = ref.current\n    if (!img?.complete || img.naturalWidth !== 0) return\n    const id = requestAnimationFrame(() => setFailed(src ?? null))\n    return () => cancelAnimationFrame(id)\n  }, [src])\n\n  return (\n    <div className=\"relative size-full overflow-hidden rounded-[0.7rem] bg-current/10\">\n      {src && failed !== src ? (\n        <img\n          ref={ref}\n          src={src}\n          alt={alt}\n          onError={() => setFailed(src)}\n          draggable={false}\n          className=\"absolute inset-0 size-full object-cover\"\n        />\n      ) : (\n        <div className=\"absolute inset-0 bg-current/5\" />\n      )}\n    </div>\n  )\n}\n\nfunction Card({ card, index, className }: { card: StickyCardItem; index: number; className?: string }) {\n  return (\n    <article\n      className={cn(\n        \"grid overflow-hidden rounded-[1.1rem] shadow-[0_30px_60px_-20px_rgb(0_0_0/0.6)] @lg:grid-cols-[1fr_44%]\",\n        className\n      )}\n      style={cardColors(card, index)}\n    >\n      <div className=\"flex flex-col justify-between gap-8 p-[clamp(1.5rem,4.8cqw,2.75rem)]\">\n        <p className=\"font-mono text-[clamp(0.7rem,1.3cqw,0.875rem)] uppercase tracking-[0.04em] opacity-75\">\n          {card.tag}\n        </p>\n        <h3\n          className=\"max-w-[16ch] text-[clamp(1.75rem,4.4cqw,3rem)] font-bold uppercase leading-[1.02] tracking-[-0.005em]\"\n          style={{ fontFamily: DISPLAY_FONT }}\n        >\n          {card.title}\n        </h3>\n      </div>\n      <div className=\"hidden p-[clamp(1.25rem,3.9cqw,2.25rem)] @lg:block @lg:pl-0\">\n        <CardImage src={card.image} alt={card.title} />\n      </div>\n    </article>\n  )\n}\n\nexport function StickyCards({\n  cards,\n  className = \"\",\n  cardYOffset = 4,\n  cardScaleStep = 0.05,\n  stepInterval = 1.2,\n  stepDuration = 1,\n  scrollLengthPerCard = 1.8,\n  exitLast = false,\n  background = \"var(--background)\",\n  scroller,\n  height = \"100vh\",\n}: StickyCardsProps) {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const stickyRef = useRef<HTMLElement>(null)\n  const reduced = useReducedMotion()\n\n  useGSAP(\n    () => {\n      if (reduced) return // fallback markup (below) handles this case instead\n\n      const sticky = stickyRef.current\n      if (!sticky) return\n\n      const cardEls = gsap.utils.toArray<HTMLElement>(\"[data-sticky-card]\", sticky)\n      const totalCards = cardEls.length\n      if (!totalCards) return\n      const steps = exitLast ? totalCards : totalCards - 1\n\n      // Stack: every card is the same size; the ones behind are scaled down from\n      // their bottom edge and nudged lower so a sliver of each peeks out.\n      cardEls.forEach((card, index) => {\n        gsap.set(card, {\n          xPercent: -50,\n          yPercent: -50 + index * cardYOffset,\n          scale: 1 - index * cardScaleStep,\n          rotationX: 0,\n          opacity: 1,\n          transformOrigin: \"center bottom\",\n        })\n      })\n\n      const tl = gsap.timeline({\n        scrollTrigger: {\n          trigger: sticky,\n          scroller,\n          start: \"top top\",\n          end: () => `+=${sticky.offsetHeight * scrollLengthPerCard * Math.max(steps, 1)}px`,\n          pin: true,\n          pinSpacing: true,\n          scrub: 1.4,\n          invalidateOnRefresh: true,\n          anticipatePin: 1,\n        },\n      })\n\n      for (let step = 0; step < steps; step++) {\n        const timePos = step * stepInterval\n\n        // Front card lifts up and away\n        tl.to(\n          cardEls[step],\n          { yPercent: -230, rotationX: 25, opacity: 0, duration: stepDuration, ease: \"power2.inOut\" },\n          timePos\n        )\n\n        // Everything behind moves one slot forward\n        for (let behind = step + 1; behind < totalCards; behind++) {\n          const slot = behind - (step + 1)\n          tl.to(\n            cardEls[behind],\n            {\n              yPercent: -50 + slot * cardYOffset,\n              scale: 1 - slot * cardScaleStep,\n              duration: stepDuration,\n              ease: \"power2.inOut\",\n            },\n            timePos\n          )\n        }\n      }\n\n      const refreshTimer = window.setTimeout(() => ScrollTrigger.refresh(), 100)\n      return () => window.clearTimeout(refreshTimer)\n    },\n    {\n      scope: containerRef,\n      dependencies: [cards, reduced, exitLast, scroller, cardYOffset, cardScaleStep, stepInterval, stepDuration, scrollLengthPerCard],\n      revertOnUpdate: true,\n    }\n  )\n\n  if (reduced) {\n    // Accessible fallback: a plain list, no pin, no motion.\n    return (\n      <div ref={containerRef} className={cn(\"@container space-y-6 p-6\", className)} style={{ background }}>\n        {cards.map((card, i) => (\n          <Card key={card.id} card={card} index={i} className=\"mx-auto aspect-[16/9] max-w-5xl\" />\n        ))}\n      </div>\n    )\n  }\n\n  return (\n    <div ref={containerRef} className={className}>\n      <section\n        ref={stickyRef}\n        className=\"@container relative w-full overflow-hidden [perspective:1600px]\"\n        style={{ height, background }}\n      >\n        {cards.map((card, i) => (\n          <div\n            key={card.id}\n            data-sticky-card\n            className=\"absolute left-1/2 top-1/2 w-[min(88%,58rem)]\"\n            // First card sits on top of the stack\n            style={{ zIndex: cards.length - i }}\n          >\n            <Card card={card} index={i} className=\"aspect-[4/5] @lg:aspect-[16/9]\" />\n          </div>\n        ))}\n      </section>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/sticky-cards",
  "type": "registry:component"
}