{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shingle",
  "type": "registry:ui",
  "title": "Shingle",
  "description": "A scroll-pinned card stack: full-width cards slide up and seat at a fixed point with a 2px settle-and-lock, then pile into a receding deck as later cards pin over them — depth carried by cool desaturation, a hair of scale and a soft downward shadow, never color. Reverses cleanly and falls back to a plain legible list under reduced motion.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/scroll/shingle/shingle.tsx",
      "type": "registry:ui",
      "target": "components/crucible/shingle.tsx",
      "content": "\"use client\";\n\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useScroll,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ShingleProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /**\n   * The cards. Each top-level child becomes one full-width card that pins and\n   * stacks. Falls back to a neutral demo deck when omitted.\n   */\n  children?: React.ReactNode;\n  /**\n   * Scroll container to track instead of the window. Pass a ref to an\n   * internal `overflow-y-auto` element (this component's own demo does) so the\n   * pin position is measured against the scrollport, not the page.\n   */\n  scrollContainer?: React.RefObject<HTMLElement | null>;\n  /**\n   * Vertical gap in px between cards in normal flow — also the scroll distance\n   * that advances the deck by one card. @default 44\n   */\n  itemDistance?: number;\n  /**\n   * Pin anchor: how far below the top of the scrollport a card seats. Number =\n   * px, string = any CSS length/percent (e.g. `\"18%\"`). @default \"18%\"\n   */\n  stackPosition?: number | string;\n  /** Scale removed per card of depth as the deck recedes. @default 0.05 */\n  itemScale?: number;\n  /** Upward peek in px per card of depth, so receding cards show above the front. @default 12 */\n  itemStackDistance?: number;\n  /** How many cards deep the recession clamps (the deepest visible layer). @default 4 */\n  maxDepth?: number;\n  /** Blur radius in px added per card of depth. @default 1.5 */\n  blurAmount?: number;\n  /** Rotation in degrees per card of depth (alternating sign for an overlapped-tile feel). @default 0 */\n  rotationAmount?: number;\n  /**\n   * The signature detent: px of overshoot as a card seats at the pin point\n   * (tiny settle-and-lock). `0` disables it. @default 2\n   */\n  settle?: number;\n  /** Fired once when the final card seats at the pin. */\n  onStackComplete?: () => void;\n}\n\nconst DEFAULTS = {\n  itemDistance: 44,\n  stackPosition: \"18%\" as number | string,\n  itemScale: 0.05,\n  itemStackDistance: 12,\n  maxDepth: 4,\n  blurAmount: 1.5,\n  rotationAmount: 0,\n  settle: 2,\n};\n\nconst CARD_CLASS =\n  \"relative overflow-hidden rounded-2xl border border-white/10 \" +\n  \"bg-[linear-gradient(180deg,#15161a_0%,#101114_100%)] p-6 text-white sm:p-8 \" +\n  \"shadow-[0_28px_64px_-32px_rgba(0,0,0,0.9),inset_0_1px_0_rgba(255,255,255,0.05)]\";\n\nfunction clamp(v: number, min: number, max: number) {\n  return v < min ? min : v > max ? max : v;\n}\n\nfunction resolveStack(sp: number | string, containerHeight: number): number {\n  if (typeof sp === \"number\") return sp;\n  const s = sp.trim();\n  if (s.endsWith(\"%\")) return (containerHeight * (parseFloat(s) || 0)) / 100;\n  return parseFloat(s) || 0;\n}\n\ninterface CardCommon {\n  index: number;\n  total: number;\n  scrollY: MotionValue<number>;\n  scrollContainer?: React.RefObject<HTMLElement | null>;\n  itemDistance: number;\n  stackPosition: number | string;\n  itemScale: number;\n  itemStackDistance: number;\n  maxDepth: number;\n  blurAmount: number;\n  rotationAmount: number;\n  settle: number;\n  onSeat: (index: number) => void;\n  children: React.ReactNode;\n}\n\nfunction ShingleCard({\n  index,\n  total,\n  scrollY,\n  scrollContainer,\n  itemDistance,\n  stackPosition,\n  itemScale,\n  itemStackDistance,\n  maxDepth,\n  blurAmount,\n  rotationAmount,\n  settle,\n  onSeat,\n  children,\n}: CardCommon) {\n  const anchorRef = React.useRef<HTMLDivElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  // Measured layout: scroll position at which this card seats, and the scroll\n  // span that advances the deck by one card. Held in a ref (non-reactive);\n  // `remeasure` bumps to force the transforms to recompute after a relayout.\n  const layout = React.useRef({ pinStart: 0, spacing: 1 });\n  const remeasure = useMotionValue(0);\n  // Tiny settle-and-lock overshoot, animated imperatively when the card seats.\n  const settleY = useMotionValue(0);\n  const seated = React.useRef(false);\n  const dir = index % 2 === 0 ? 1 : -1;\n\n  React.useEffect(() => {\n    const measure = () => {\n      const anchor = anchorRef.current;\n      const card = cardRef.current;\n      if (!anchor || !card) return;\n      const cont = scrollContainer?.current ?? null;\n      const containerTop = cont ? cont.getBoundingClientRect().top : 0;\n      const containerH = cont\n        ? cont.clientHeight\n        : typeof window !== \"undefined\"\n          ? window.innerHeight\n          : 0;\n      const stackPx = resolveStack(stackPosition, containerH);\n      // Anchor is a zero-height static marker: its viewport top plus the current\n      // scroll offset yields a scroll-invariant flow position.\n      const anchorTop = anchor.getBoundingClientRect().top;\n      layout.current.pinStart = scrollY.get() + (anchorTop - containerTop) - stackPx;\n      layout.current.spacing = Math.max(card.offsetHeight + itemDistance, 1);\n      remeasure.set(remeasure.get() + 1);\n    };\n\n    measure();\n    const raf = requestAnimationFrame(measure);\n    const ro = new ResizeObserver(measure);\n    if (cardRef.current) ro.observe(cardRef.current);\n    const cont = scrollContainer?.current;\n    if (cont) ro.observe(cont);\n    window.addEventListener(\"resize\", measure);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [scrollContainer, stackPosition, itemDistance, scrollY, remeasure]);\n\n  // Depth: 0 = the front card (or still entering), rising by one per card that\n  // pins on top, clamped to the deepest visible layer — and to the number of\n  // cards that actually exist above this one, so the last card (nothing pins\n  // over it) stays crisp at the front instead of receding into the runway.\n  const depthCap = Math.max(0, Math.min(maxDepth, total - 1 - index));\n  const depth = useTransform(() => {\n    remeasure.get();\n    const { pinStart, spacing } = layout.current;\n    return clamp((scrollY.get() - pinStart) / spacing, 0, depthCap);\n  });\n\n  // Fire the settle exactly as the card crosses its pin, going down.\n  useMotionValueEvent(scrollY, \"change\", (v) => {\n    const pin = layout.current.pinStart;\n    if (pin <= 8) return; // cards that start already pinned don't need a detent.\n    if (!seated.current && v >= pin) {\n      seated.current = true;\n      if (settle > 0) {\n        settleY.set(settle);\n        animate(settleY, 0, { type: \"spring\", stiffness: 620, damping: 14, mass: 0.5 });\n      }\n      onSeat(index);\n    } else if (seated.current && v < pin - 6) {\n      seated.current = false;\n    }\n  });\n\n  const y = useTransform([depth, settleY], ([d, s]: number[]) => -d * itemStackDistance + s);\n  const scale = useTransform(depth, (d) => 1 - d * itemScale);\n  const rotate = useTransform(depth, (d) => d * rotationAmount * dir);\n  const filter = useTransform(depth, (d) => {\n    const t = maxDepth > 0 ? Math.min(d, maxDepth) / maxDepth : 0;\n    return `blur(${(d * blurAmount).toFixed(2)}px) saturate(${(1 - t * 0.5).toFixed(3)}) brightness(${(1 - t * 0.12).toFixed(3)})`;\n  });\n  // Depth veil: a slate-cool wash with a downward inner-shadow — depth read as\n  // desaturation + shadow, never a color shift.\n  const veilOpacity = useTransform(depth, (d) => (maxDepth > 0 ? Math.min(d / maxDepth, 1) * 0.5 : 0));\n\n  return (\n    <>\n      <div ref={anchorRef} aria-hidden className=\"pointer-events-none h-0\" />\n      <motion.div\n        ref={cardRef}\n        className={cn(\"sticky\", CARD_CLASS)}\n        style={{\n          top: stackPosition,\n          y,\n          scale,\n          rotate,\n          filter,\n          zIndex: index,\n          marginBottom: itemDistance,\n          transformOrigin: \"center top\",\n          willChange: \"transform, filter\",\n        }}\n      >\n        {children}\n        <motion.span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 rounded-[inherit]\"\n          style={{\n            opacity: veilOpacity,\n            background: \"linear-gradient(180deg, rgba(30,41,59,0.35) 0%, rgba(15,23,42,0.55) 100%)\",\n            boxShadow: \"inset 0 -26px 44px -20px rgba(0,0,0,0.75)\",\n          }}\n        />\n      </motion.div>\n    </>\n  );\n}\n\nfunction StaticCard({\n  itemDistance,\n  children,\n}: {\n  itemDistance: number;\n  children: React.ReactNode;\n}) {\n  return (\n    <div className={CARD_CLASS} style={{ marginBottom: itemDistance }}>\n      {children}\n    </div>\n  );\n}\n\nfunction defaultDeck(): React.ReactNode[] {\n  const cards = [\n    [\"Pin\", \"Each card slides up and locks at a fixed point in the scrollport.\"],\n    [\"Stack\", \"The card beneath scales down and lifts a hair, peeking behind the front.\"],\n    [\"Recede\", \"Depth reads as desaturation and a soft downward shadow — not a color.\"],\n    [\"Seat\", \"Every card settles with a 2px overshoot before it locks into the deck.\"],\n  ];\n  // One card per entry — the fallback is a real deck, not a single card.\n  return cards.map(([title, body], i) => (\n    <div key={title} className=\"space-y-2\">\n      <p className=\"text-xs font-medium tracking-widest text-white/40 tabular-nums uppercase\">\n        {String(i + 1).padStart(2, \"0\")}\n      </p>\n      <p className=\"text-xl font-semibold tracking-tight\">{title}</p>\n      <p className=\"max-w-sm text-sm text-white/60\">{body}</p>\n    </div>\n  ));\n}\n\n/**\n * Shingle — a scroll-pinned card stack. A vertical run of full-width cards pins\n * at a fixed point in the scrollport and piles into a receding deck: each\n * incoming card slides up and seats with a tiny 2px settle-and-lock, while the\n * cards beneath scale down, lift a hair, desaturate and blur — depth carried by\n * shadow and cool desaturation rather than color. Scrolling back reverses it\n * cleanly. Drive it off an internal `overflow-y-auto` container via\n * `scrollContainer`. Under `prefers-reduced-motion` it renders a plain,\n * fully-legible vertical list with no pin, scale or blur.\n */\nexport function Shingle({\n  children,\n  scrollContainer,\n  itemDistance = DEFAULTS.itemDistance,\n  stackPosition = DEFAULTS.stackPosition,\n  itemScale = DEFAULTS.itemScale,\n  itemStackDistance = DEFAULTS.itemStackDistance,\n  maxDepth = DEFAULTS.maxDepth,\n  blurAmount = DEFAULTS.blurAmount,\n  rotationAmount = DEFAULTS.rotationAmount,\n  settle = DEFAULTS.settle,\n  onStackComplete,\n  className,\n  ...props\n}: ShingleProps) {\n  const reducedMotion = useReducedMotion();\n  const { scrollY } = useScroll({ container: scrollContainer });\n\n  const items = React.useMemo(() => {\n    const arr = React.Children.toArray(children);\n    return arr.length > 0 ? arr : null;\n  }, [children]);\n\n  const content = React.useMemo(() => items ?? defaultDeck(), [items]);\n  const completed = React.useRef(false);\n  const total = content.length;\n  const handleSeat = React.useCallback(\n    (index: number) => {\n      if (index === total - 1 && !completed.current) {\n        completed.current = true;\n        onStackComplete?.();\n      }\n    },\n    [total, onStackComplete]\n  );\n\n  return (\n    <div data-crucible=\"shingle\" className={cn(\"relative w-full\", className)} {...props}>\n      {reducedMotion\n        ? content.map((child, i) => (\n            <StaticCard key={i} itemDistance={itemDistance}>\n              {child}\n            </StaticCard>\n          ))\n        : content.map((child, i) => (\n            <ShingleCard\n              key={i}\n              index={i}\n              total={total}\n              scrollY={scrollY}\n              scrollContainer={scrollContainer}\n              itemDistance={itemDistance}\n              stackPosition={stackPosition}\n              itemScale={itemScale}\n              itemStackDistance={itemStackDistance}\n              maxDepth={maxDepth}\n              blurAmount={blurAmount}\n              rotationAmount={rotationAmount}\n              settle={settle}\n              onSeat={handleSeat}\n            >\n              {child}\n            </ShingleCard>\n          ))}\n    </div>\n  );\n}\n"
    }
  ]
}