{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "layer-stack",
  "type": "registry:ui",
  "title": "Layer Stack",
  "description": "Interactive stacked card layout with drag and scroll momentum.",
  "author": "Ahdeetai <https://aditya.is-cool.dev>",
  "registryDependencies": ["@scrollxui/progress"],
  "dependencies": [
    "motion",
    "@radix-ui/react-progress",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "type": "registry:ui",
      "path": "components/ui/layer-stack.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport {\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useTransform,\n  useSpring,\n  animate,\n  type MotionValue,\n} from 'motion/react';\nimport { cn } from '@/lib/utils';\nimport { Progress } from '@/components/ui/progress';\n\ninterface LayerStackProps {\n  children: React.ReactNode;\n  className?: string;\n  cardWidth?: number;\n  cardGap?: number;\n  stageHeight?: number;\n  lastCardFullWidth?: boolean;\n  mobileSensitivity?: number;\n  ref?: React.Ref<HTMLDivElement>;\n}\n\ninterface CardProps extends React.HTMLAttributes<HTMLDivElement> {\n  children: React.ReactNode;\n  className?: string;\n  ref?: React.Ref<HTMLDivElement>;\n}\n\ninterface StackCardInternalProps {\n  children: React.ReactElement<CardProps>;\n  index: number;\n  cardWidth: number;\n  cardGap: number;\n  scrollProgress: MotionValue<number>;\n  maxScroll: number;\n  renderedWidth: number;\n  isMobile: boolean;\n  cardRef?: (el: HTMLDivElement | null) => void;\n}\n\nfunction Card({ children, className, ref, ...props }: CardProps) {\n  return (\n    <div\n      ref={ref}\n      className={cn('size-full overflow-hidden rounded-[inherit]', className)}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nconst StackCardInternal = React.memo(function StackCardInternal({\n  children,\n  index,\n  cardWidth,\n  cardGap,\n  scrollProgress,\n  maxScroll,\n  renderedWidth,\n  isMobile,\n  cardRef,\n}: StackCardInternalProps) {\n  const naturalX = index * (cardWidth + cardGap);\n  const x = useTransform(\n    scrollProgress,\n    [0, 1],\n    [naturalX, naturalX - maxScroll],\n  );\n  const stackDepth = useTransform(x, (v) => Math.max(0, -v));\n  const clampedX = useTransform(x, (v) => Math.max(0, v));\n  const scale = useTransform(stackDepth, [0, cardWidth * 3], [1, 0.95]);\n  const yDrop = useTransform(\n    stackDepth,\n    [0, cardWidth],\n    [0, isMobile ? 6 : 12],\n  );\n  const rotateYMobile = useMotionValue(0);\n  const rotateYDesktop = useTransform(stackDepth, [0, cardWidth * 2], [0, -4]);\n  const rotateY = isMobile ? rotateYMobile : rotateYDesktop;\n\n  return (\n    <motion.div\n      ref={cardRef}\n      style={{\n        x: clampedX,\n        y: yDrop,\n        scale,\n        rotateY,\n        zIndex: index + 1,\n        width: renderedWidth,\n        position: 'absolute',\n        top: 0,\n        left: 0,\n        height: '100%',\n        borderRadius: 12,\n        overflow: 'hidden',\n        transformOrigin: 'bottom center',\n        willChange: 'transform',\n      }}\n    >\n      {React.cloneElement(children, {\n        style: {\n          ...children.props.style,\n          width: '100%',\n          height: '100%',\n          margin: 0,\n          boxSizing: 'border-box' as const,\n        },\n      })}\n    </motion.div>\n  );\n});\n\nfunction LayerStack({\n  children,\n  className,\n  cardWidth = 320,\n  cardGap = 16,\n  stageHeight = 400,\n  lastCardFullWidth = false,\n  mobileSensitivity = 2,\n  ref,\n}: LayerStackProps) {\n  const cards = React.Children.toArray(\n    children,\n  ) as React.ReactElement<CardProps>[];\n\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const rawScroll = React.useRef(0);\n  const velocityRef = React.useRef(0);\n  const lastTouchTime = React.useRef(0);\n  const lastTouchX = React.useRef(0);\n  const rafRef = React.useRef<number | null>(null);\n  const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n\n  const [progressValue, setProgressValue] = React.useState(0);\n  const [containerWidth, setContainerWidth] = React.useState(0);\n  const [isMobile, setIsMobile] = React.useState(false);\n  const [autoHeight, setAutoHeight] = React.useState(stageHeight);\n\n  React.useEffect(() => {\n    const checkMobile = () => setIsMobile(window.innerWidth < 768);\n    checkMobile();\n    const mediaQuery = window.matchMedia('(max-width: 767px)');\n    const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  React.useEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => {\n      setContainerWidth(entry.contentRect.width);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const effectiveCardWidth = React.useMemo(() => {\n    if (isMobile && containerWidth > 0) return containerWidth - 32;\n    return cardWidth;\n  }, [isMobile, containerWidth, cardWidth]);\n\n  React.useEffect(() => {\n    if (!isMobile || containerWidth === 0) {\n      setAutoHeight(stageHeight);\n      return;\n    }\n    const frame = requestAnimationFrame(() => {\n      const heights = cardRefs.current.map((el) => el?.scrollHeight ?? 0);\n      const max = Math.max(...heights);\n      if (max > 0) setAutoHeight(max + 32);\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [isMobile, containerWidth, effectiveCardWidth, stageHeight]);\n\n  const scrollX = useMotionValue(0);\n  const smoothScrollX = useSpring(scrollX, {\n    stiffness: isMobile ? 250 : 400,\n    damping: isMobile ? 30 : 40,\n    mass: isMobile ? 0.4 : 0.5,\n    restDelta: 0.001,\n    restSpeed: 0.001,\n  });\n\n  useMotionValueEvent(scrollX, 'change', (v) => {\n    setProgressValue(v * 100);\n  });\n\n  const maxScroll = React.useMemo(\n    () =>\n      Math.max(\n        0,\n        cards.length * (effectiveCardWidth + cardGap) -\n          cardGap -\n          effectiveCardWidth,\n      ),\n    [cards.length, effectiveCardWidth, cardGap],\n  );\n\n  const updateScrollX = React.useCallback(\n    (px: number) => {\n      const clamped = Math.max(0, Math.min(maxScroll, px));\n      rawScroll.current = clamped;\n      scrollX.set(maxScroll > 0 ? clamped / maxScroll : 0);\n    },\n    [maxScroll, scrollX],\n  );\n\n  React.useEffect(() => {\n    if (isMobile) return;\n    const el = stageRef.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      updateScrollX(rawScroll.current + e.deltaY * 0.5);\n    };\n    el.addEventListener('wheel', onWheel, { passive: false });\n    return () => el.removeEventListener('wheel', onWheel);\n  }, [updateScrollX, isMobile]);\n\n  React.useEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n\n    let startX = 0;\n    let startY = 0;\n    let startScroll = 0;\n    let isTouching = false;\n    let isHorizontalScroll = false;\n\n    const onTouchStart = (e: TouchEvent) => {\n      if (rafRef.current) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n      isTouching = true;\n      isHorizontalScroll = false;\n      startX = e.touches[0].clientX;\n      startY = e.touches[0].clientY;\n      startScroll = rawScroll.current;\n      lastTouchX.current = startX;\n      lastTouchTime.current = Date.now();\n      velocityRef.current = 0;\n    };\n\n    const onTouchMove = (e: TouchEvent) => {\n      if (!isTouching) return;\n      const currentX = e.touches[0].clientX;\n      const currentY = e.touches[0].clientY;\n      const deltaX = Math.abs(currentX - startX);\n      const deltaY = Math.abs(currentY - startY);\n\n      if (!isHorizontalScroll && deltaX < 10 && deltaY < 10) return;\n      if (!isHorizontalScroll) isHorizontalScroll = deltaX > deltaY;\n\n      if (isHorizontalScroll) {\n        e.preventDefault();\n        const currentTime = Date.now();\n        const scrollDeltaX = (startX - currentX) * mobileSensitivity;\n        const deltaTime = currentTime - lastTouchTime.current;\n\n        if (deltaTime > 0) {\n          velocityRef.current = (lastTouchX.current - currentX) / deltaTime;\n        }\n\n        lastTouchX.current = currentX;\n        lastTouchTime.current = currentTime;\n\n        if (rafRef.current) cancelAnimationFrame(rafRef.current);\n        rafRef.current = requestAnimationFrame(() => {\n          updateScrollX(startScroll + scrollDeltaX);\n          rafRef.current = null;\n        });\n      }\n    };\n\n    const onTouchEnd = () => {\n      if (!isHorizontalScroll) {\n        isTouching = false;\n        return;\n      }\n      isTouching = false;\n      const velocity = velocityRef.current * 250 * mobileSensitivity;\n      if (Math.abs(velocity) > 15) {\n        const momentum = velocity * 0.4;\n        const targetScroll = rawScroll.current + momentum;\n        const clampedTarget = Math.max(0, Math.min(maxScroll, targetScroll));\n        animate(rawScroll.current, clampedTarget, {\n          type: 'spring',\n          stiffness: 100,\n          damping: 18,\n          mass: 0.3,\n          onUpdate: (v) => updateScrollX(v),\n        });\n      }\n    };\n\n    el.addEventListener('touchstart', onTouchStart, { passive: true });\n    el.addEventListener('touchmove', onTouchMove, { passive: false });\n    el.addEventListener('touchend', onTouchEnd, { passive: true });\n    el.addEventListener('touchcancel', onTouchEnd, { passive: true });\n\n    return () => {\n      el.removeEventListener('touchstart', onTouchStart);\n      el.removeEventListener('touchmove', onTouchMove);\n      el.removeEventListener('touchend', onTouchEnd);\n      el.removeEventListener('touchcancel', onTouchEnd);\n      if (rafRef.current) cancelAnimationFrame(rafRef.current);\n    };\n  }, [updateScrollX, maxScroll, mobileSensitivity]);\n\n  React.useEffect(() => {\n    if (isMobile) return;\n\n    let isDragging = false;\n    let dragStartX = 0;\n    let dragStartScroll = 0;\n\n    const onMouseDown = (e: MouseEvent) => {\n      if (e.button !== 0) return;\n      isDragging = true;\n      dragStartX = e.clientX;\n      dragStartScroll = rawScroll.current;\n      e.preventDefault();\n    };\n\n    const onMouseMove = (e: MouseEvent) => {\n      if (!isDragging) return;\n      updateScrollX(dragStartScroll + (dragStartX - e.clientX));\n    };\n\n    const onMouseUp = () => {\n      isDragging = false;\n    };\n\n    const el = stageRef.current;\n    if (!el) return;\n\n    el.addEventListener('mousedown', onMouseDown);\n    window.addEventListener('mousemove', onMouseMove);\n    window.addEventListener('mouseup', onMouseUp);\n\n    return () => {\n      el.removeEventListener('mousedown', onMouseDown);\n      window.removeEventListener('mousemove', onMouseMove);\n      window.removeEventListener('mouseup', onMouseUp);\n    };\n  }, [updateScrollX, isMobile]);\n\n  const onProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    const ratio = (e.clientX - rect.left) / rect.width;\n    const target = ratio * maxScroll;\n    const start = rawScroll.current;\n    animate(0, 1, {\n      duration: 0.5,\n      ease: [0.32, 0, 0.67, 0],\n      onUpdate: (v) => updateScrollX(start + (target - start) * v),\n    });\n  };\n\n  return (\n    <div ref={ref} className={cn('w-full select-none', className)}>\n      <div\n        ref={stageRef}\n        className={cn(\n          'relative w-full overflow-hidden rounded-xl',\n          !isMobile && 'cursor-grab active:cursor-grabbing',\n        )}\n        style={{\n          height: autoHeight,\n          perspective: isMobile ? 1000 : 1400,\n          transition: 'height 0.2s ease',\n        }}\n      >\n        {cards.map((card, i) => {\n          const isLastCard = i === cards.length - 1;\n          const renderedWidth =\n            lastCardFullWidth && isLastCard && containerWidth > 0\n              ? containerWidth\n              : effectiveCardWidth;\n\n          return (\n            <StackCardInternal\n              key={i}\n              index={i}\n              cardWidth={effectiveCardWidth}\n              cardGap={cardGap}\n              scrollProgress={smoothScrollX}\n              maxScroll={maxScroll}\n              renderedWidth={renderedWidth}\n              isMobile={isMobile}\n              cardRef={(el: HTMLDivElement | null) => {\n                cardRefs.current[i] = el;\n              }}\n            >\n              {card}\n            </StackCardInternal>\n          );\n        })}\n      </div>\n\n      <div className='mt-4 flex flex-col items-center gap-2'>\n        <Progress\n          variant='slim'\n          value={progressValue}\n          onClick={onProgressClick}\n          className='w-60 cursor-pointer'\n        />\n        <p className='text-[10px] font-medium tracking-[0.14em] uppercase text-muted-foreground'>\n          {isMobile ? 'drag' : 'drag or scroll'}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport { LayerStack, Card };\nexport type { LayerStackProps, CardProps };"
    },
    {
      "type": "registry:ui",
      "path": "components/ui/progress.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport * as ProgressPrimitive from '@radix-ui/react-progress';\nimport { cn } from '@/lib/utils';\n\ninterface ProgressProps extends React.ComponentProps<\n  typeof ProgressPrimitive.Root\n> {\n  variant?: 'default' | 'slim' | 'outline-solid';\n  indicatorClassName?: string;\n  indicatorStyle?: React.CSSProperties;\n}\n\nfunction Progress({\n  className,\n  value,\n  variant = 'default',\n  indicatorClassName,\n  indicatorStyle,\n  ...props\n}: ProgressProps) {\n  const isSlim = variant === 'slim';\n  const isOutline = variant === 'outline-solid';\n\n  return (\n    <ProgressPrimitive.Root\n      data-slot='progress'\n      className={cn(\n        'bg-primary/20 relative w-full overflow-hidden rounded-full border h-3',\n        isSlim && 'bg-background border-black dark:border-white',\n        isOutline &&\n          'bg-primary/20 relative w-full overflow-hidden rounded-full border h-3 border-black dark:border-white',\n        className,\n      )}\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        data-slot='progress-indicator'\n        className={cn(\n          'bg-primary transition-all',\n          isSlim\n            ? 'absolute top-1/2 -translate-y-1/2 h-[60%] rounded-full'\n            : 'h-full',\n          indicatorClassName,\n        )}\n        style={\n          isSlim\n            ? {\n                left: '4px',\n                width: `calc(${value || 0}% - 8px)`,\n                ...indicatorStyle,\n              }\n            : { width: `${value || 0}%`, ...indicatorStyle }\n        }\n      />\n    </ProgressPrimitive.Root>\n  );\n}\n\nexport { Progress };\n"
    },
    {
      "type": "registry:lib",
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}"
    }
  ]
}
