{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An image revealed by an animated mask as it scrolls into view, with a counter-parallax on the image.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type RefObject, useRef } from \"react\";\n\n/** Motion scroll offset tuple, typed from `useScroll` itself. */\nexport type ScrollOffset = NonNullable<\n  Parameters<typeof useScroll>[0]\n>[\"offset\"];\n\nconst DEFAULT_PARALLAX_PX = 60;\nconst CIRCLE_MAX_RADIUS_PERCENT = 90;\nconst BLIND_BAND_SIZE_PX = 40;\nconst SCALE_START = 1.12;\nconst FULL_SCALE = 1;\nconst FULL_OPACITY = 1;\nconst REVEAL_SPEED_MULTIPLIER = 2.5;\nconst SPRING_CONFIG = { damping: 32, mass: 0.4, stiffness: 260 } as const;\n// The payoff has to land while the frame is still comfortably in view. Running\n// the full enter-to-exit range finishes the reveal as the element leaves, so\n// the moment it was built for happens off-screen.\nconst DEFAULT_OFFSET: ScrollOffset = [\"start 0.9\", \"end 0.6\"];\nconst DEFAULT_ROUNDED = \"rounded-2xl\";\nconst NO_CLIP = \"inset(0% 0% 0% 0%)\";\n\nexport type ScrollImageRevealMask =\n  | \"wipe\"\n  | \"curtain\"\n  | \"circle\"\n  | \"blinds\"\n  | \"scale\";\n\nexport type ScrollImageRevealDirection = \"left\" | \"right\" | \"up\" | \"down\";\n\nexport interface ScrollImageRevealProps {\n  /** Accessible description of the image. Required. */\n  alt: string;\n  className?: string;\n  /** Ref to a scrollable ancestor that drives progress instead of the window. */\n  container?: RefObject<HTMLElement | null>;\n  /** Reveal direction. Only left/right apply to \"wipe\" and \"blinds\"; only up/down apply to \"curtain\". */\n  direction?: ScrollImageRevealDirection;\n  mask?: ScrollImageRevealMask;\n  /** Motion `useScroll` offset tuple, e.g. [\"start end\", \"end start\"]. */\n  offset?: ScrollOffset;\n  /** Freeze the reveal once fully open instead of re-hiding on scroll-up. */\n  once?: boolean;\n  /** Counter-parallax travel of the image, in pixels. */\n  parallax?: number;\n  /** Tailwind rounded-* class applied to the frame. */\n  rounded?: string;\n  src: string;\n}\n\nconst clampUnit = (value: number) => Math.min(1, Math.max(0, value));\n\nconst buildClipPath = (\n  mask: ScrollImageRevealMask,\n  direction: ScrollImageRevealDirection,\n  progress: number\n): string => {\n  const hiddenPercent = clampUnit(1 - progress) * 100;\n\n  if (mask === \"wipe\") {\n    return direction === \"right\"\n      ? `inset(0% 0% 0% ${hiddenPercent}%)`\n      : `inset(0% ${hiddenPercent}% 0% 0%)`;\n  }\n\n  if (mask === \"curtain\") {\n    return direction === \"down\"\n      ? `inset(${hiddenPercent}% 0% 0% 0%)`\n      : `inset(0% 0% ${hiddenPercent}% 0%)`;\n  }\n\n  if (mask === \"circle\") {\n    const radius = clampUnit(progress) * CIRCLE_MAX_RADIUS_PERCENT;\n    return `circle(${radius}% at 50% 50%)`;\n  }\n\n  return NO_CLIP;\n};\n\nconst buildBlindsMask = (\n  direction: ScrollImageRevealDirection,\n  progress: number\n): string => {\n  const revealPx = clampUnit(progress) * BLIND_BAND_SIZE_PX;\n  const axis =\n    direction === \"left\" || direction === \"right\" ? \"to right\" : \"to bottom\";\n\n  return `repeating-linear-gradient(${axis}, #000 0px, #000 ${revealPx}px, transparent ${revealPx}px, transparent ${BLIND_BAND_SIZE_PX}px)`;\n};\n\nexport default function ScrollImageReveal({\n  src,\n  alt,\n  mask = \"wipe\",\n  direction = \"left\",\n  parallax = DEFAULT_PARALLAX_PX,\n  offset = DEFAULT_OFFSET,\n  container,\n  once = false,\n  rounded = DEFAULT_ROUNDED,\n  className,\n}: ScrollImageRevealProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n  const maxRevealRef = useRef(0);\n\n  const { scrollYProgress } = useScroll({ container, offset, target: ref });\n  const baseSpring = useSpring(scrollYProgress, SPRING_CONFIG);\n\n  const revealProgress = useTransform(baseSpring, (latest) => {\n    const scaled = clampUnit(latest * REVEAL_SPEED_MULTIPLIER);\n    if (!once) {\n      return scaled;\n    }\n    maxRevealRef.current = Math.max(maxRevealRef.current, scaled);\n    return maxRevealRef.current;\n  });\n\n  // The mask lives on the frame, not the image: the image is deliberately\n  // taller than the frame so the parallax has somewhere to travel, and a clip\n  // measured against that oversized box would never line up with the edges.\n  const clipPath = useTransform(revealProgress, (latest) =>\n    shouldReduceMotion || mask === \"blinds\" || mask === \"scale\"\n      ? NO_CLIP\n      : buildClipPath(mask, direction, latest)\n  );\n\n  const maskImage = useTransform(revealProgress, (latest) =>\n    shouldReduceMotion || mask !== \"blinds\"\n      ? \"none\"\n      : buildBlindsMask(direction, latest)\n  );\n\n  // Scales *down* into place from an overscan, never up from smaller than the\n  // frame — scaling up from 0.85 would expose the background at the edges.\n  const scale = useTransform(revealProgress, (latest) =>\n    shouldReduceMotion || mask !== \"scale\"\n      ? FULL_SCALE\n      : SCALE_START + (FULL_SCALE - SCALE_START) * clampUnit(latest)\n  );\n\n  const revealOpacity = useTransform(revealProgress, (latest) =>\n    shouldReduceMotion || mask !== \"scale\" ? FULL_OPACITY : clampUnit(latest)\n  );\n\n  const overscan = shouldReduceMotion ? 0 : Math.abs(parallax);\n\n  const parallaxY = useTransform(baseSpring, (latest) =>\n    shouldReduceMotion ? 0 : overscan - latest * overscan * 2\n  );\n\n  return (\n    <motion.div\n      className={cn(\"relative overflow-hidden\", rounded, className)}\n      ref={ref}\n      style={{\n        clipPath,\n        maskImage,\n        WebkitMaskImage: maskImage,\n      }}\n    >\n      <motion.img\n        alt={alt}\n        className=\"absolute inset-x-0 w-full object-cover\"\n        decoding=\"async\"\n        src={src}\n        style={{\n          // Overscan on both edges so the counter-parallax never drags the\n          // image off its own frame and leaves a strip of background behind.\n          height: `calc(100% + ${overscan * 2}px)`,\n          opacity: revealOpacity,\n          scale,\n          top: -overscan,\n          y: parallaxY,\n        }}\n      />\n    </motion.div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/scroll-image-reveal/index.tsx","type":"registry:ui"}],"name":"scroll-image-reveal","registryDependencies":[],"title":"Scroll Image Reveal","type":"registry:ui"}