{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fan-stack",
  "title": "Fan Stack",
  "author": "AlgorizeTech",
  "description": "Ask the user to pick one of 6 scroll stack variants: https://avaraui.com/view/scroll-stack. This is the Fan stack. Skip the question if the user already named one. Overlapping cards in a tilted diagonal cascade, dealt out of one pile into place as the stack scrolls into view. Use it for a short numbered sequence — steps, phases, a three-part system.",
  "dependencies": [
    "gsap",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/FanStackBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { Check } from \"lucide-react\";\nimport React, {\n  Children,\n  createContext,\n  forwardRef,\n  isValidElement,\n  useContext,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nif (typeof window !== \"undefined\") {\n  gsap.registerPlugin(ScrollTrigger);\n}\n\n// ==========================================\n// GEOMETRY\n// ==========================================\n/**\n * The stack is a cascade from this container width up (Tailwind's `@3xl`,\n * 48rem); below it the cards simply stack, unanimated.\n */\nconst CASCADE_MIN_WIDTH = 768;\n/** Each card's resting tilt in the cascade, by position (repeats). */\nconst REST_TILT = [-1.5, 1, -1];\n/** The extra tilt each card is dealt from, by position (repeats). */\nconst DEAL_TILT = [-5, 4, -3, 2];\n\nexport type FanStackTone = \"muted\" | \"light\" | \"accent\";\nexport type FanStackThumbSide = \"right\" | \"left\" | \"top\";\n\nconst TONES: FanStackTone[] = [\"muted\", \"light\", \"accent\"];\nconst THUMB_SIDES: FanStackThumbSide[] = [\"right\", \"left\", \"top\"];\n\n/**\n * Card surfaces, all derived from `--primary`: a faint wash over the card\n * colour, then two soft OKLCH tints with an ink of the same hue, so any theme\n * colour gives a calm, legible cascade in light and dark mode.\n */\nconst TONE_STYLE: Record<FanStackTone, React.CSSProperties> = {\n  muted: {\n    background: \"color-mix(in oklab, var(--primary) 14%, var(--card))\",\n    color: \"var(--card-foreground)\",\n    borderColor: \"var(--border)\",\n  },\n  light: {\n    background: \"oklch(from var(--primary) 0.97 calc(c * 0.25) h)\",\n    color: \"oklch(from var(--primary) 0.24 calc(c * 0.5) h)\",\n    borderColor: \"oklch(from var(--primary) 0.9 calc(c * 0.4) h)\",\n  },\n  accent: {\n    background: \"oklch(from var(--primary) 0.88 calc(c * 0.4) h)\",\n    color: \"oklch(from var(--primary) 0.3 calc(c * 0.6) h)\",\n    borderColor: \"oklch(from var(--primary) 0.8 calc(c * 0.5) h)\",\n  },\n};\n\n// Where a photo hangs off its card in the cascade, clear of the card's text.\nconst THUMB_SIDE_CLASS: Record<FanStackThumbSide, string> = {\n  right: \"@3xl:-top-[30px] @3xl:-right-[calc(var(--fan-thumb)*0.84)] @3xl:rotate-[7deg]\",\n  left: \"@3xl:top-[46%] @3xl:right-auto @3xl:left-[max(calc(var(--fan-thumb)*-0.98),calc(12px_-_var(--fan-shift)*38cqw))] @3xl:-rotate-[6deg]\",\n  top: \"@3xl:-top-[calc(var(--fan-thumb)*0.6)] @3xl:right-2 @3xl:rotate-[5deg]\",\n};\n\n// ==========================================\n// CONTEXT\n// ==========================================\ninterface FanStackCardContextValue {\n  index: number;\n  count: number;\n}\n\nconst FanStackCardContext = createContext<FanStackCardContextValue>({ index: 0, count: 1 });\n\n// ==========================================\n// FAN STACK\n// ==========================================\nexport interface FanStackProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * The scrolling element, when the stack lives inside a scroll container\n   * rather than the page. Defaults to the window.\n   */\n  scrollerRef?: React.RefObject<HTMLElement | null>;\n  /** Seconds the cards take to catch up with the scrollbar. `0` locks them to it. */\n  scrub?: number;\n  /**\n   * Below 768px, the gap in px between the top of the scroller and the first\n   * stuck card — raise it to clear a fixed header.\n   */\n  stickyTop?: number;\n}\n\n/**\n * Overlapping cards in a diagonal cascade. As the stack scrolls into view the\n * cards are dealt out of one tilted pile into place, scrubbed by scroll (not\n * under reduced motion). Below 768px of width the cards stack instead: each\n * sticks near the top and the next slides over it, leaving its header peeking.\n */\nexport const FanStack = forwardRef<HTMLDivElement, FanStackProps>(\n  ({ scrollerRef, scrub = 0.6, stickyTop = 16, className, children, ...props }, ref) => {\n    const listRef = useRef<HTMLDivElement>(null);\n    const [cascade, setCascade] = useState(false);\n    const cards = Children.toArray(children).filter(isValidElement);\n    const count = cards.length;\n\n    // The layout switches on the container's width (a container query), so\n    // the animation follows the same width rather than the viewport's.\n    useLayoutEffect(() => {\n      const list = listRef.current;\n      if (!list) return;\n      const measure = () => setCascade(list.clientWidth >= CASCADE_MIN_WIDTH);\n      measure();\n      const observer = new ResizeObserver(measure);\n      observer.observe(list);\n      return () => observer.disconnect();\n    }, []);\n\n    useLayoutEffect(() => {\n      const list = listRef.current;\n      if (!list || !cascade) return;\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const items = gsap.utils.toArray<HTMLElement>(\"[data-fan-card]\", list);\n        // The pile is the middle card: dealing from there keeps every card\n        // (and the photos hanging off them) inside the stack's width.\n        const pile = items[Math.floor((items.length - 1) / 2)];\n        if (!pile) return;\n        // Every card starts on the pile, tilted, and is dealt to the place the\n        // cascade gives it.\n        gsap.from(items, {\n          x: (_: number, card: HTMLElement) => pile.offsetLeft - card.offsetLeft,\n          y: (_: number, card: HTMLElement) => pile.offsetTop - card.offsetTop,\n          rotation: (i: number) => DEAL_TILT[i % DEAL_TILT.length],\n          ease: \"none\",\n          scrollTrigger: {\n            trigger: list,\n            scroller: scrollerRef?.current ?? undefined,\n            start: \"top 75%\",\n            end: \"center 45%\",\n            scrub: scrub > 0 ? scrub : true,\n          },\n        });\n        // GSAP folds the card's CSS `rotate` into its own transform and keeps\n        // that inline after reverting; hand both back to the stylesheet.\n        return () => gsap.set(items, { clearProps: \"transform,translate,rotate,scale\" });\n      });\n      return () => mm.revert();\n    }, [cascade, count, scrollerRef, scrub]);\n\n    return (\n      <div ref={ref} className={cn(\"@container w-full\", className)} {...props}>\n        <div\n          ref={listRef}\n          className=\"grid gap-5 @3xl:block @3xl:pt-6 @3xl:pb-10\"\n          style={\n            {\n              \"--fan-thumb\": \"min(190px, 18cqw)\",\n              \"--fan-top\": `${stickyTop}px`,\n              // Narrow: every card as tall as the tallest, so each one fully\n              // covers the card it stacks on.\n              gridTemplateRows: `repeat(${Math.max(count, 1)}, 1fr) auto`,\n            } as React.CSSProperties\n          }\n        >\n          {cards.map((card, index) => (\n            <FanStackCardContext.Provider key={card.key ?? index} value={{ index, count }}>\n              {card}\n            </FanStackCardContext.Provider>\n          ))}\n          {/* Narrow: room for the finished stack to hold before it scrolls on.\n              A sticky card is held only while its parent has room below it. */}\n          <div aria-hidden=\"true\" className=\"h-40 @3xl:hidden\" />\n        </div>\n      </div>\n    );\n  },\n);\nFanStack.displayName = \"FanStack\";\n\n// ==========================================\n// CARD\n// ==========================================\nexport interface FanStackCardProps extends React.HTMLAttributes<HTMLElement> {\n  /** The card's surface. Defaults to muted → light → accent by position. */\n  tone?: FanStackTone;\n}\n\nexport const FanStackCard = forwardRef<HTMLElement, FanStackCardProps>(\n  ({ tone, className, style, children, ...props }, ref) => {\n    const { index, count } = useContext(FanStackCardContext);\n    const surface = tone ?? TONES[index % TONES.length];\n    return (\n      <article\n        ref={ref}\n        data-fan-card=\"\"\n        data-fan-index={index}\n        className={cn(\n          \"grid content-start gap-3.5 rounded-[26px] border p-6 sm:p-8\",\n          \"shadow-[0_24px_60px_-30px_rgb(0_0_0/0.35)]\",\n          // Narrow: a sticky stack, each card stuck a step lower than the last\n          // so the headers underneath stay readable. The title clears the\n          // small photo pinned to the card's corner.\n          \"sticky top-[calc(var(--fan-top)+var(--fan-i)*20px)] [&:has(>figure)>h3]:pr-24\",\n          \"@3xl:relative @3xl:top-auto @3xl:[&:has(>figure)>h3]:pr-0\",\n          // The cascade: each card narrower than the stack, stepped right and\n          // tucked under the one before it. The next card overlaps only this\n          // card's bottom padding, never its text.\n          \"@3xl:w-[62%] @3xl:grid-cols-2 @3xl:gap-x-8 @3xl:-mt-8\",\n          index === count - 1 ? \"@3xl:pb-8\" : \"@3xl:pb-[76px]\",\n          \"@3xl:ml-[calc(var(--fan-shift)*38%)] @3xl:rotate-[var(--fan-tilt)]\",\n          \"@3xl:first:mt-0\",\n          className,\n        )}\n        style={\n          {\n            ...TONE_STYLE[surface],\n            zIndex: index + 1,\n            \"--fan-i\": index,\n            // 0 for the first card, 1 for the last: how far along the 38% it steps.\n            \"--fan-shift\": count > 1 ? index / (count - 1) : 0,\n            \"--fan-tilt\": `${REST_TILT[index % REST_TILT.length]}deg`,\n            ...style,\n          } as React.CSSProperties\n        }\n        {...props}\n      >\n        {children}\n      </article>\n    );\n  },\n);\nFanStackCard.displayName = \"FanStackCard\";\n\n// ==========================================\n// CARD PARTS\n// ==========================================\nexport const FanStackTitle = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(\n  ({ className, ...props }, ref) => (\n    <h3\n      ref={ref}\n      className={cn(\n        \"m-0 text-[1.75rem] font-medium leading-tight tracking-[-0.03em] @3xl:col-span-full @3xl:text-[2.5rem]\",\n        className,\n      )}\n      {...props}\n    />\n  ),\n);\nFanStackTitle.displayName = \"FanStackTitle\";\n\n/** The step number ahead of a title, set back a shade. */\nexport const FanStackStep = forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(\n  ({ className, ...props }, ref) => (\n    <span ref={ref} className={cn(\"opacity-55 tabular-nums\", className)} {...props} />\n  ),\n);\nFanStackStep.displayName = \"FanStackStep\";\n\nexport const FanStackDescription = forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <p\n    ref={ref}\n    className={cn(\"m-0 max-w-[32rem] text-base leading-relaxed opacity-85\", className)}\n    {...props}\n  />\n));\nFanStackDescription.displayName = \"FanStackDescription\";\n\nexport const FanStackPoints = forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\n  ({ className, ...props }, ref) => (\n    <ul\n      ref={ref}\n      className={cn(\"m-0 grid list-none content-start gap-2 p-0 text-[0.9375rem]\", className)}\n      {...props}\n    />\n  ),\n);\nFanStackPoints.displayName = \"FanStackPoints\";\n\nexport interface FanStackPointProps extends React.LiHTMLAttributes<HTMLLIElement> {\n  /** Replaces the check mark. */\n  icon?: React.ReactNode;\n}\n\nexport const FanStackPoint = forwardRef<HTMLLIElement, FanStackPointProps>(\n  ({ icon, className, children, ...props }, ref) => (\n    <li ref={ref} className={cn(\"flex gap-2.5\", className)} {...props}>\n      {icon ?? <Check aria-hidden=\"true\" className=\"mt-0.5 size-[18px] shrink-0 opacity-80\" />}\n      <span>{children}</span>\n    </li>\n  ),\n);\nFanStackPoint.displayName = \"FanStackPoint\";\n\nexport interface FanStackThumbProps extends React.HTMLAttributes<HTMLElement> {\n  /**\n   * Where the photo hangs off the card in the cascade. Defaults to right →\n   * left → top by position. Below the cascade width it is a small photo in\n   * the card's top corner.\n   */\n  side?: FanStackThumbSide;\n}\n\n/** A photo for the card — pass an `<img>` (or your framework's image) as the child. */\nexport const FanStackThumb = forwardRef<HTMLElement, FanStackThumbProps>(\n  ({ side, className, ...props }, ref) => {\n    const { index } = useContext(FanStackCardContext);\n    const placement = side ?? THUMB_SIDES[index % THUMB_SIDES.length];\n    return (\n      <figure\n        ref={ref}\n        className={cn(\n          \"m-0 aspect-[22/17] overflow-hidden rounded-[14px] border border-white/15\",\n          \"shadow-[0_20px_50px_-20px_rgb(0_0_0/0.55)]\",\n          \"[&_img]:block [&_img]:size-full [&_img]:object-cover\",\n          // Narrow: a small photo inside the card's top corner.\n          \"absolute top-7 right-5 w-20 rotate-[4deg] rounded-[10px]\",\n          \"@3xl:w-[var(--fan-thumb)] @3xl:rounded-[14px]\",\n          THUMB_SIDE_CLASS[placement],\n          className,\n        )}\n        {...props}\n      />\n    );\n  },\n);\nFanStackThumb.displayName = \"FanStackThumb\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/FanStackBlock.tsx"
    }
  ],
  "docs": "Named exports: FanStack, FanStackCard, FanStackTitle, FanStackStep, FanStackDescription, FanStackPoints, FanStackPoint, FanStackThumb (plus the FanStackTone and FanStackThumbSide types). Compose cards as children — any number works; each card's `tone` (muted | light | accent) and its thumb's `side` (right | left | top) default by position. `FanStackThumb` takes your own `<img>` or framework image as its child. Colours derive from `--primary`. The cascade is a container query: below 768px of width the cards become a CSS sticky stack, each sliding over the last (`stickyTop` clears a fixed header). The deal is scrubbed by scroll (`scrub` seconds of lag, 0 to lock it); pass `scrollerRef` when it scrolls inside a container. The deal is skipped under prefers-reduced-motion.",
  "categories": [
    "scroll",
    "marketing"
  ],
  "type": "registry:component"
}
