{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-scroll-fade",
  "title": "Scroll fades and blur ramp",
  "description": "Edge feathers that appear only where there is more to scroll (scrollFadeMask, useScrollEdges) and the stacked masked backdrop layers behind Progressive Blur (blurRamp, BlurStack).",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/scroll-fade/scroll-fade.tsx",
      "content": "\"use client\";\n\nimport { motion, type MotionValue } from \"motion/react\";\nimport { useEffect, type CSSProperties, type RefObject } from \"react\";\n\nexport type FadeAxis = \"x\" | \"y\" | \"both\";\nexport type BlurSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\n/* ---------- Scroll fades ---------------------------------------------------------------- */\n\nconst EDGE_VARS = {\n  x: [\"--scroll-area-overflow-x-start\", \"--scroll-area-overflow-x-end\"],\n  y: [\"--scroll-area-overflow-y-start\", \"--scroll-area-overflow-y-end\"],\n} as const;\n\nconst edgeGradient = (axis: \"x\" | \"y\", size: number) => {\n  const [start, end] = EDGE_VARS[axis];\n  const dir = axis === \"y\" ? \"to bottom\" : \"to right\";\n  // Each feather grows with the distance left to scroll, so it fades in continuously as\n  // content appears past the edge and is gone when the edge is reached: no fade, no step.\n  return `linear-gradient(${dir}, transparent, #000 min(${size}px, var(${start}, 0px)), #000 calc(100% - min(${size}px, var(${end}, 0px))), transparent)`;\n};\n\n/**\n * A mask that feathers a scroller's edges only where there is more to scroll (R94). It reads\n * the distance-to-edge variables Base UI's ScrollArea.Viewport sets; `useScrollEdges` sets the\n * same variables on any other scroller. Put it on the scrolling element itself.\n */\nexport function scrollFadeMask(axis: FadeAxis = \"y\", size = 20): CSSProperties {\n  const image = axis === \"both\" ? `${edgeGradient(\"y\", size)}, ${edgeGradient(\"x\", size)}` : edgeGradient(axis, size);\n  const composite = axis === \"both\" ? { maskComposite: \"intersect\", WebkitMaskComposite: \"source-in\" } : {};\n  return { maskImage: image, WebkitMaskImage: image, maskRepeat: \"no-repeat\", ...composite } as CSSProperties;\n}\n\n/**\n * Calls `update` whenever a scroller's own box or any of its children resize, including\n * children added or removed later (a list that grows). Returns the cleanup.\n */\nexport function observeScrollContent(el: HTMLElement, update: () => void) {\n  const ro = new ResizeObserver(update);\n  ro.observe(el);\n  for (const child of el.children) ro.observe(child);\n  const mo = new MutationObserver((records) => {\n    for (const r of records) for (const node of r.addedNodes) if (node instanceof Element) ro.observe(node);\n    update();\n  });\n  mo.observe(el, { childList: true });\n  return () => {\n    ro.disconnect();\n    mo.disconnect();\n  };\n}\n\n/**\n * Keeps `--scroll-area-overflow-{x,y}-{start,end}` on a plain scroller in sync with how far it\n * can still scroll each way, in px, so `scrollFadeMask` works without Base UI.\n */\nexport function useScrollEdges(ref: RefObject<HTMLElement | null>, axis: FadeAxis = \"y\") {\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const update = () => {\n      if (axis !== \"x\") {\n        const top = el.scrollTop;\n        el.style.setProperty(EDGE_VARS.y[0], `${Math.max(0, top)}px`);\n        el.style.setProperty(EDGE_VARS.y[1], `${Math.max(0, el.scrollHeight - el.clientHeight - top)}px`);\n      }\n      if (axis !== \"y\") {\n        const left = Math.abs(el.scrollLeft);\n        el.style.setProperty(EDGE_VARS.x[0], `${Math.max(0, left)}px`);\n        el.style.setProperty(EDGE_VARS.x[1], `${Math.max(0, el.scrollWidth - el.clientWidth - left)}px`);\n      }\n    };\n    update();\n    el.addEventListener(\"scroll\", update, { passive: true });\n    const unobserve = observeScrollContent(el, update);\n    return () => {\n      el.removeEventListener(\"scroll\", update);\n      unobserve();\n    };\n  }, [ref, axis]);\n}\n\n/* ---------- Progressive blur ------------------------------------------------------------ */\n\nexport type BlurLayer = {\n  /** backdrop-filter blur radius, px. */\n  blur: number;\n  /** The mask band this layer is visible in. */\n  mask: string;\n};\n\nconst DIRECTION: Record<BlurSide, string> = { top: \"to top\", bottom: \"to bottom\", left: \"to left\", right: \"to right\" };\n\n/**\n * The layers of a progressive blur. Each layer doubles the last one's radius and is masked to\n * an overlapping band, so blur ramps from 0 at the clear end to `maxBlur` at the `side` edge\n * without the visible step a single blurred strip has. Six layers to 20px is the Eased default.\n */\nexport function blurRamp({ side = \"top\", layers = 6, maxBlur = 20 }: { side?: BlurSide; layers?: number; maxBlur?: number } = {}): BlurLayer[] {\n  const n = Math.max(2, Math.round(layers));\n  const step = 100 / n;\n  const pct = (k: number) => `${Math.min(100, Math.max(0, k * step)).toFixed(2)}%`;\n  return Array.from({ length: n }, (_, i) => {\n    const blur = maxBlur / 2 ** (n - 1 - i);\n    // Band i fades in over [i, i+1] steps from the clear end, holds, then fades out over\n    // [i+2, i+3]; bands past the edge simply stay solid there.\n    const stops = [`transparent ${pct(i)}`, `#000 ${pct(i + 1)}`];\n    if (i + 2 < n) stops.push(`#000 ${pct(i + 2)}`, `transparent ${pct(i + 3)}`);\n    return { blur: Number(blur.toFixed(3)), mask: `linear-gradient(${DIRECTION[side]}, ${stops.join(\", \")})` };\n  });\n}\n\nexport type BlurStackProps = {\n  /** The edge the blur is strongest at. */\n  side?: BlurSide;\n  layers?: number;\n  maxBlur?: number;\n  /** 0–1. Fades every layer together; a MotionValue keeps scroll-linked strength off React's render. */\n  strength?: number | MotionValue<number>;\n  className?: string;\n  style?: CSSProperties;\n};\n\n/**\n * The stacked, masked backdrop layers of `blurRamp`, filling their positioned parent. Strength\n * is applied as each layer's own opacity: an opacity, filter or mask on an ancestor would\n * become the backdrop root and blank the blur, so the parent must not set them.\n */\nexport function BlurStack({ side = \"top\", layers = 6, maxBlur = 20, strength = 1, className = \"\", style }: BlurStackProps) {\n  const ramp = blurRamp({ side, layers, maxBlur });\n  return (\n    <div aria-hidden className={`pointer-events-none absolute inset-0 ${className}`} style={style}>\n      {ramp.map((layer, i) => (\n        <motion.div\n          key={i}\n          className=\"absolute inset-0\"\n          style={{\n            opacity: strength,\n            backdropFilter: `blur(${layer.blur}px)`,\n            WebkitBackdropFilter: `blur(${layer.blur}px)`,\n            maskImage: layer.mask,\n            WebkitMaskImage: layer.mask,\n          }}\n        />\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/scroll-fade/scroll-fade.tsx"
    }
  ],
  "type": "registry:ui"
}