{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magazine-scroller","title":"Magazine Scroller","author":"CoDesign-Spa27","description":"A velocity-aware poster scroller with drag, wheel, spring, and progressive-blur treatments.","dependencies":["motion"],"files":[{"path":"components/ui-components/magazine-scroller.tsx","content":"\"use client\";\n\nimport {\n  type MotionValue,\n  motion,\n  useAnimationFrame,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport type { PointerEvent as ReactPointerEvent } from \"react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { ProgressiveBlur } from \"../ui/progressive-blur\";\n\ntype Poster = {\n  src: string;\n  alt?: string;\n};\n\nexport type MagazineScrollerSpringConfig = {\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  visualDuration?: number;\n  bounce?: number;\n};\n\nexport type MagazineScrollerProps = {\n  images?: Poster[];\n  cardWidth?: number;\n  cardHeight?: number;\n  gap?: number;\n  slices?: number;\n  height?: number | string;\n  wheelSpeed?: number;\n  dragSpeed?: number;\n  autoSpeed?: number;\n  bendStrength?: number;\n  maxBend?: number;\n  lockWheel?: boolean;\n  positionSpring?: MagazineScrollerSpringConfig;\n  velocitySpring?: MagazineScrollerSpringConfig;\n  className?: string;\n};\n\nconst DEFAULT_IMAGES: Poster[] = [\n  \"image.jpeg\",\n  \"image1.jpeg\",\n  \"image2.jpg\",\n  \"image3.jpg\",\n  \"image4.jpeg\",\n  \"image5.jpeg\",\n  \"image6.jpeg\",\n  \"image7.jpeg\",\n  \"image8.jpeg\",\n  \"image9.jpeg\",\n  \"image10.jpeg\",\n  \"image11.jpeg\",\n].map((file, index) => ({\n  src: `/images/magazine-scroller/${file}`,\n  alt: `Magazine poster ${index + 1}`,\n}));\n\nconst POSITION_SPRING = {\n  mass: 0.28,\n  stiffness: 95,\n  damping: 24,\n} satisfies MagazineScrollerSpringConfig;\n\nconst VELOCITY_SPRING = {\n  mass: 0.18,\n  stiffness: 80,\n  damping: 34,\n} satisfies MagazineScrollerSpringConfig;\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction wrap(min: number, max: number, value: number) {\n  const range = max - min;\n  if (range === 0) return min;\n  return ((((value - min) % range) + range) % range) + min;\n}\n\nfunction toCssSize(value: number | string) {\n  return typeof value === \"number\" ? `${value}px` : value;\n}\n\nexport function MagazineScroller({\n  images,\n  cardWidth = 170,\n  cardHeight = 250,\n  gap = 34,\n  slices = 9,\n  height = \"70vh\",\n  wheelSpeed = 1.15,\n  dragSpeed = 1.15,\n  autoSpeed = 0,\n  bendStrength = 82,\n  maxBend = 100,\n  lockWheel = true,\n  positionSpring = POSITION_SPRING,\n  velocitySpring = VELOCITY_SPRING,\n  className,\n}: MagazineScrollerProps) {\n  const rootRef = useRef<HTMLElement | null>(null);\n  const dragStartXRef = useRef(0);\n  const dragStartValueRef = useRef(0);\n  const isDraggingRef = useRef(false);\n\n  const [containerWidth, setContainerWidth] = useState(0);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const targetX = useMotionValue(0);\n  const x = useSpring(targetX, positionSpring);\n\n  const safeImages = useMemo(() => {\n    return images?.length ? images : DEFAULT_IMAGES;\n  }, [images]);\n\n  const safeSlices = Math.max(1, Math.floor(slices));\n  const itemStep = cardWidth + gap;\n  const loopWidth = safeImages.length * itemStep;\n\n  const loopX = useTransform(x, (latest) => {\n    return wrap(-loopWidth, 0, latest);\n  });\n\n  const velocity = useVelocity(x);\n\n  const smoothVelocity = useSpring(velocity, velocitySpring);\n\n  const bend = useTransform(smoothVelocity, (latest) => {\n    return clamp(latest / bendStrength, -maxBend, maxBend);\n  });\n\n  useEffect(() => {\n    const node = rootRef.current;\n    if (!node) return;\n\n    const updateSize = () => {\n      setContainerWidth(node.clientWidth);\n    };\n\n    updateSize();\n\n    const observer = new ResizeObserver(updateSize);\n    observer.observe(node);\n\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const node = rootRef.current;\n    if (!node || !lockWheel) return;\n\n    const handleWheel = (event: WheelEvent) => {\n      const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;\n\n      if (!delta) return;\n\n      event.preventDefault();\n      targetX.set(targetX.get() - delta * wheelSpeed);\n    };\n\n    node.addEventListener(\"wheel\", handleWheel, { passive: false });\n\n    return () => {\n      node.removeEventListener(\"wheel\", handleWheel);\n    };\n  }, [lockWheel, targetX, wheelSpeed]);\n\n  useAnimationFrame((_, delta) => {\n    if (!autoSpeed || isDraggingRef.current) return;\n\n    targetX.set(targetX.get() - autoSpeed * (delta / 1000));\n  });\n\n  const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {\n    if (event.button !== 0) return;\n\n    isDraggingRef.current = true;\n    setIsDragging(true);\n\n    dragStartXRef.current = event.clientX;\n    dragStartValueRef.current = targetX.get();\n\n    event.currentTarget.setPointerCapture(event.pointerId);\n  };\n\n  const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {\n    if (!isDraggingRef.current) return;\n\n    const distance = event.clientX - dragStartXRef.current;\n    targetX.set(dragStartValueRef.current + distance * dragSpeed);\n  };\n\n  const stopDragging = (event: ReactPointerEvent<HTMLElement>) => {\n    if (!isDraggingRef.current) return;\n\n    isDraggingRef.current = false;\n    setIsDragging(false);\n\n    try {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    } catch {\n      // Pointer may already be released by the browser.\n    }\n  };\n\n  // === ProgressiveBlur implementation ===\n  // We'll render a left and right <ProgressiveBlur> element overlayed on top\n  // with pointerEvents: \"none\" so they do not block scroll/drag.\n\n  return (\n    <section\n      ref={rootRef}\n      className={className}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={stopDragging}\n      onPointerCancel={stopDragging}\n      style={{\n        position: \"relative\",\n        height: toCssSize(height),\n        width: \"100%\",\n        overflow: \"hidden\",\n        display: \"flex\",\n        alignItems: \"center\",\n        cursor: isDragging ? \"grabbing\" : \"grab\",\n        userSelect: \"none\",\n        touchAction: \"pan-y\",\n        perspective: 1400,\n      }}\n    >\n      {/* LEFT progressive blur */}\n      <div\n        style={{\n          position: \"absolute\",\n          top: 0,\n          left: 0,\n          width: 56,\n          height: \"100%\",\n          zIndex: 2,\n          pointerEvents: \"none\",\n        }}\n      >\n        <ProgressiveBlur position=\"right\" width=\"100%\" height=\"100%\" />\n      </div>\n\n      <motion.div\n        style={{\n          x: loopX,\n          display: \"flex\",\n          alignItems: \"center\",\n          gap,\n          transformStyle: \"preserve-3d\",\n          willChange: \"transform\",\n        }}\n      >\n        {[0, 1, 2].map((copyIndex) =>\n          safeImages.map((image, index) => {\n            const absoluteIndex = copyIndex * safeImages.length + index;\n\n            return (\n              <PosterCard\n                key={`${copyIndex}-${image.src}`}\n                image={image}\n                absoluteIndex={absoluteIndex}\n                loopX={loopX}\n                bend={bend}\n                cardWidth={cardWidth}\n                cardHeight={cardHeight}\n                itemStep={itemStep}\n                slices={safeSlices}\n                containerWidth={containerWidth}\n              />\n            );\n          }),\n        )}\n      </motion.div>\n\n      <div\n        style={{\n          position: \"absolute\",\n          top: 0,\n          right: 0,\n          width: 56,\n          height: \"100%\",\n          zIndex: 2,\n          pointerEvents: \"none\",\n        }}\n      >\n        <ProgressiveBlur position=\"left\" width=\"100%\" height=\"100%\" />\n      </div>\n    </section>\n  );\n}\n\ntype PosterCardProps = {\n  image: Poster;\n  absoluteIndex: number;\n  loopX: MotionValue<number>;\n  bend: MotionValue<number>;\n  cardWidth: number;\n  cardHeight: number;\n  itemStep: number;\n  slices: number;\n  containerWidth: number;\n};\n\nfunction PosterCard({\n  image,\n  absoluteIndex,\n  loopX,\n  bend,\n  cardWidth,\n  cardHeight,\n  itemStep,\n  slices,\n  containerWidth,\n}: PosterCardProps) {\n  const cardCenter = useTransform(loopX, (latestX) => {\n    return latestX + absoluteIndex * itemStep + cardWidth / 1;\n  });\n\n  const rotateY = useTransform(cardCenter, (center) => {\n    if (!containerWidth) return 0;\n\n    const viewportCenter = containerWidth / 2;\n    const distance = (center - viewportCenter) / viewportCenter;\n\n    return clamp(distance * -62, -76, 76);\n  });\n\n  const scale = useTransform(cardCenter, (center) => {\n    if (!containerWidth) return 1;\n\n    const viewportCenter = containerWidth / 2;\n    const distance = Math.abs(center - viewportCenter) / viewportCenter;\n\n    return clamp(1 - distance * 0.15, 0.82, 1);\n  });\n\n  const opacity = useTransform(cardCenter, (center) => {\n    if (!containerWidth) return 1;\n\n    const viewportCenter = containerWidth / 2;\n    const distance = Math.abs(center - viewportCenter) / viewportCenter;\n\n    return clamp(1 - distance * 0.38, 0.48, 1);\n  });\n\n  const y = useTransform(cardCenter, (center) => {\n    if (!containerWidth) return 0;\n\n    const viewportCenter = containerWidth / 2;\n    const distance = Math.abs(center - viewportCenter) / viewportCenter;\n\n    return clamp(distance * 30, 0, 44);\n  });\n\n  const rotateZ = useTransform(bend, (latest) => latest * 0.08);\n\n  return (\n    <motion.article\n      aria-label={image.alt ?? \"Magazine poster\"}\n      style={{\n        width: cardWidth,\n        height: cardHeight,\n        flex: \"0 0 auto\",\n        y,\n        rotateY,\n        rotateZ,\n        scale,\n        opacity,\n        transformPerspective: 1200,\n        transformStyle: \"preserve-3d\",\n        willChange: \"transform\",\n      }}\n    >\n      <div\n        style={{\n          width: \"100%\",\n          height: \"100%\",\n          display: \"flex\",\n          overflow: \"hidden\",\n          borderRadius: 4,\n\n          transformStyle: \"preserve-3d\",\n        }}\n      >\n        {Array.from({ length: slices }).map((_, sliceIndex) => (\n          <PosterSlice\n            // The generated slice position is its stable identity within a poster.\n            // biome-ignore lint/suspicious/noArrayIndexKey: Slices do not reorder.\n            key={`${image.src}-${sliceIndex}`}\n            src={image.src}\n            sliceIndex={sliceIndex}\n            slices={slices}\n            bend={bend}\n          />\n        ))}\n      </div>\n    </motion.article>\n  );\n}\n\ntype PosterSliceProps = {\n  src: string;\n  sliceIndex: number;\n  slices: number;\n  bend: MotionValue<number>;\n};\n\nfunction PosterSlice({ src, sliceIndex, slices, bend }: PosterSliceProps) {\n  const middle = (slices - 1) / 2;\n  const offsetFromMiddle = sliceIndex - middle;\n\n  const sliceRotateY = useTransform(bend, (latest) => {\n    return latest * offsetFromMiddle * 0.22;\n  });\n\n  const sliceSkewY = useTransform(bend, (latest) => {\n    return latest * offsetFromMiddle * 0.014;\n  });\n\n  const sliceZ = useTransform(bend, (latest) => {\n    return Math.abs(latest) * Math.abs(offsetFromMiddle) * -0.18;\n  });\n\n  const backgroundPosition = slices === 1 ? \"50% 50%\" : `${(sliceIndex / (slices - 1)) * 100}% 50%`;\n\n  return (\n    <motion.div\n      style={{\n        width: `${100 / slices}%`,\n        height: \"100%\",\n        rotateY: sliceRotateY,\n        skewY: sliceSkewY,\n        z: sliceZ,\n        transformOrigin: \"center center\",\n        backgroundImage: `url(${src})`,\n        backgroundSize: `${slices * 100}% 100%`,\n        backgroundPosition,\n        backgroundRepeat: \"no-repeat\",\n        backfaceVisibility: \"hidden\",\n        willChange: \"transform\",\n      }}\n    />\n  );\n}\n\nexport default MagazineScroller;\n","type":"registry:ui"},{"path":"components/ui/progressive-blur.tsx","content":"\"use client\";\n\nimport type React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface ProgressiveBlurProps {\n  className?: string;\n  height?: string;\n  width?: string;\n  position?: \"top\" | \"bottom\" | \"left\" | \"right\" | \"both\";\n  blurLevels?: number[];\n  children?: React.ReactNode;\n}\n\nexport function ProgressiveBlur({\n  className,\n  height = \"30%\",\n  width = \"30%\",\n  position = \"bottom\",\n  blurLevels = [0.5, 1, 2, 4, 8, 16, 32, 64],\n}: ProgressiveBlurProps) {\n  const divElements = Array(blurLevels.length - 2).fill(null);\n  const isHorizontal = position === \"left\" || position === \"right\";\n  const fullMask =\n    \"linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 5%, rgba(0,0,0,1) 95%, rgba(0,0,0,0) 100%)\";\n\n  return (\n    <div\n      className={cn(\n        \"gradient-blur pointer-events-none absolute z-10\",\n        position === \"top\"\n          ? \"top-0\"\n          : position === \"bottom\"\n            ? \"bottom-0\"\n            : position === \"left\"\n              ? \"left-0\"\n              : position === \"right\"\n                ? \"right-0\"\n                : \"inset-y-0\",\n        isHorizontal ? \"inset-y-0\" : \"inset-x-0\",\n        className,\n      )}\n      style={{\n        height: isHorizontal || position === \"both\" ? \"100%\" : height,\n        width: isHorizontal ? width : undefined,\n      }}\n    >\n      <div\n        className=\"absolute inset-0\"\n        style={{\n          zIndex: 1,\n          backdropFilter: `blur(${blurLevels[0]}px)`,\n          WebkitBackdropFilter: `blur(${blurLevels[0]}px)`,\n          maskImage:\n            position === \"bottom\"\n              ? \"linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n              : position === \"top\"\n                ? \"linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n                : position === \"left\"\n                  ? \"linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n                  : position === \"right\"\n                    ? \"linear-gradient(to left, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n                    : fullMask,\n          WebkitMaskImage:\n            position === \"bottom\"\n              ? \"linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n              : position === \"top\"\n                ? \"linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n                : position === \"left\"\n                  ? \"linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n                  : position === \"right\"\n                    ? \"linear-gradient(to left, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 12.5%, rgba(0,0,0,1) 25%, rgba(0,0,0,0) 37.5%)\"\n                    : fullMask,\n        }}\n      />\n\n      {divElements.map((_, index) => {\n        const blurIndex = index + 1;\n        const startPercent = blurIndex * 12.5;\n        const midPercent = (blurIndex + 1) * 12.5;\n        const endPercent = (blurIndex + 2) * 12.5;\n\n        const maskGradient =\n          position === \"bottom\"\n            ? `linear-gradient(to bottom, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${\n                endPercent + 12.5\n              }%)`\n            : position === \"top\"\n              ? `linear-gradient(to top, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${\n                  endPercent + 12.5\n                }%)`\n              : position === \"left\"\n                ? `linear-gradient(to right, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${\n                    endPercent + 12.5\n                  }%)`\n                : position === \"right\"\n                  ? `linear-gradient(to left, rgba(0,0,0,0) ${startPercent}%, rgba(0,0,0,1) ${midPercent}%, rgba(0,0,0,1) ${endPercent}%, rgba(0,0,0,0) ${\n                      endPercent + 12.5\n                    }%)`\n                  : fullMask;\n\n        return (\n          <div\n            key={`blur-${blurLevels[blurIndex]}`}\n            className=\"absolute inset-0\"\n            style={{\n              zIndex: index + 2,\n              backdropFilter: `blur(${blurLevels[blurIndex]}px)`,\n              WebkitBackdropFilter: `blur(${blurLevels[blurIndex]}px)`,\n              maskImage: maskGradient,\n              WebkitMaskImage: maskGradient,\n            }}\n          />\n        );\n      })}\n\n      <div\n        className=\"absolute inset-0\"\n        style={{\n          zIndex: blurLevels.length,\n          backdropFilter: `blur(${blurLevels[blurLevels.length - 1]}px)`,\n          WebkitBackdropFilter: `blur(${blurLevels[blurLevels.length - 1]}px)`,\n          maskImage:\n            position === \"bottom\"\n              ? \"linear-gradient(to bottom, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n              : position === \"top\"\n                ? \"linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n                : position === \"left\"\n                  ? \"linear-gradient(to right, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n                  : position === \"right\"\n                    ? \"linear-gradient(to left, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n                    : fullMask,\n          WebkitMaskImage:\n            position === \"bottom\"\n              ? \"linear-gradient(to bottom, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n              : position === \"top\"\n                ? \"linear-gradient(to top, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n                : position === \"left\"\n                  ? \"linear-gradient(to right, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n                  : position === \"right\"\n                    ? \"linear-gradient(to left, rgba(0,0,0,0) 87.5%, rgba(0,0,0,1) 100%)\"\n                    : fullMask,\n        }}\n      />\n    </div>\n  );\n}\n","type":"registry:ui"}],"categories":["scroller","gallery"],"type":"registry:ui"}