{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A ScrollableCardStack component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nconst SCROLL_TIMEOUT_OFFSET = 100;\nconst MIN_SCROLL_INTERVAL = 300;\nconst SCROLL_THRESHOLD = 20;\nconst TOUCH_SCROLL_THRESHOLD = 100;\nconst SCALE_FACTOR = 0.08;\nconst MIN_SCALE = 0.08;\nconst MAX_SCALE = 2;\nconst HOVER_SCALE_MULTIPLIER = 1.02;\nconst CARD_PADDING = 100;\n\ninterface CardItem {\n  avatar: string;\n  handle: string;\n  href: string;\n  id: string;\n  image: string;\n  name: string;\n}\n\nexport interface ScrollableCardStackProps {\n  cardHeight?: number;\n  className?: string;\n  items: CardItem[];\n  perspective?: number;\n  transitionDuration?: number;\n}\n\nconst ScrollableCardStack: React.FC<ScrollableCardStackProps> = ({\n  items,\n  cardHeight = 384,\n  perspective = 1000,\n  transitionDuration = 180,\n  className,\n}) => {\n  const [currentIndex, setCurrentIndex] = useState(0);\n  const [isDragging, setIsDragging] = useState(false);\n  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);\n  const [isScrolling, setIsScrolling] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const scrollY = useMotionValue(0);\n  const lastScrollTime = useRef(0);\n  const shouldReduceMotion = useReducedMotion();\n\n  // Calculate the total number of items\n  const totalItems = items.length;\n  const maxIndex = totalItems - 1;\n\n  // Constants for visual effects - matching reference code exactly\n  const FRAME_OFFSET = -30;\n  const FRAMES_VISIBLE_LENGTH = 3;\n  const SNAP_DISTANCE = 50;\n\n  // Clamp function from reference code - memoized to prevent recreation\n  const clamp = useCallback(\n    (val: number, [min, max]: [number, number]): number =>\n      Math.min(Math.max(val, min), max),\n    []\n  );\n\n  // Controlled scroll function to move exactly one card\n  const scrollToCard = useCallback(\n    (direction: 1 | -1) => {\n      if (isScrolling) {\n        return;\n      }\n\n      const now = Date.now();\n      const timeSinceLastScroll = now - lastScrollTime.current;\n\n      if (timeSinceLastScroll < MIN_SCROLL_INTERVAL) {\n        return;\n      }\n\n      const newIndex = clamp(currentIndex + direction, [0, maxIndex]);\n\n      if (newIndex !== currentIndex) {\n        lastScrollTime.current = now;\n        setIsScrolling(true);\n        setCurrentIndex(newIndex);\n        scrollY.set(newIndex * SNAP_DISTANCE);\n\n        setTimeout(() => {\n          setIsScrolling(false);\n        }, transitionDuration + SCROLL_TIMEOUT_OFFSET);\n      }\n    },\n    [currentIndex, maxIndex, scrollY, isScrolling, transitionDuration, clamp]\n  );\n\n  // Handle scroll events with improved responsiveness\n  const handleScroll = useCallback(\n    (deltaY: number) => {\n      if (isDragging || isScrolling) {\n        return;\n      }\n\n      if (Math.abs(deltaY) < SCROLL_THRESHOLD) {\n        return;\n      }\n\n      const scrollDirection = deltaY > 0 ? 1 : -1;\n      scrollToCard(scrollDirection);\n    },\n    [isDragging, isScrolling, scrollToCard]\n  );\n\n  // Handle wheel events\n  const handleWheel = useCallback(\n    (e: WheelEvent) => {\n      e.preventDefault();\n      handleScroll(e.deltaY);\n    },\n    [handleScroll]\n  );\n\n  // Handle keyboard navigation - improved with reference code logic\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (isScrolling) {\n        return;\n      }\n\n      switch (e.key) {\n        case \"ArrowUp\":\n        case \"ArrowLeft\": {\n          e.preventDefault();\n          scrollToCard(-1);\n          break;\n        }\n        case \"ArrowDown\":\n        case \"ArrowRight\": {\n          e.preventDefault();\n          scrollToCard(1);\n          break;\n        }\n        case \"Home\": {\n          e.preventDefault();\n          if (currentIndex !== 0) {\n            setIsScrolling(true);\n            setCurrentIndex(0);\n            scrollY.set(0);\n            setTimeout(() => {\n              setIsScrolling(false);\n            }, transitionDuration + SCROLL_TIMEOUT_OFFSET);\n          }\n          break;\n        }\n        case \"End\": {\n          e.preventDefault();\n          if (currentIndex !== maxIndex) {\n            setIsScrolling(true);\n            setCurrentIndex(maxIndex);\n            scrollY.set(maxIndex * SNAP_DISTANCE);\n            setTimeout(() => {\n              setIsScrolling(false);\n            }, transitionDuration + SCROLL_TIMEOUT_OFFSET);\n          }\n          break;\n        }\n        default: {\n          // No action for other keys\n          break;\n        }\n      }\n    },\n    [\n      currentIndex,\n      maxIndex,\n      scrollY,\n      isScrolling,\n      scrollToCard,\n      transitionDuration,\n    ]\n  );\n\n  // Handle touch events for mobile\n  const touchStartY = useRef(0);\n  const touchStartIndex = useRef(0);\n  const touchStartTime = useRef(0);\n  const touchMoved = useRef(false);\n\n  const handleTouchStart = useCallback(\n    (e: React.TouchEvent) => {\n      touchStartY.current = e.touches[0].clientY;\n      touchStartIndex.current = currentIndex;\n      touchStartTime.current = Date.now();\n      touchMoved.current = false;\n      setIsDragging(true);\n    },\n    [currentIndex]\n  );\n\n  const handleTouchMove = useCallback(\n    (e: React.TouchEvent) => {\n      if (!isDragging || isScrolling) {\n        return;\n      }\n\n      const touchY = e.touches[0].clientY;\n      const deltaY = touchStartY.current - touchY;\n\n      if (Math.abs(deltaY) > TOUCH_SCROLL_THRESHOLD && !touchMoved.current) {\n        const scrollDirection = deltaY > 0 ? 1 : -1;\n        scrollToCard(scrollDirection);\n        touchMoved.current = true;\n      }\n    },\n    [isDragging, isScrolling, scrollToCard]\n  );\n\n  const handleTouchEnd = useCallback(() => {\n    setIsDragging(false);\n    touchMoved.current = false;\n  }, []);\n\n  // Set up event listeners\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    container.addEventListener(\"wheel\", handleWheel, { passive: false });\n\n    return () => {\n      container.removeEventListener(\"wheel\", handleWheel);\n    };\n  }, [handleWheel]);\n\n  // Snap to current index when not dragging\n  useEffect(() => {\n    if (!isDragging) {\n      scrollY.set(currentIndex * SNAP_DISTANCE);\n    }\n  }, [currentIndex, isDragging, scrollY]);\n\n  // Calculate transform for each card based on the reference code\n  const getCardTransform = useCallback(\n    (index: number) => {\n      const offsetIndex = index - currentIndex;\n\n      // Apply blur effect for cards behind the current one - matching reference exactly\n      const isBehindCurrent = currentIndex > index;\n      const blur = !shouldReduceMotion && isBehindCurrent ? 2 : 0;\n\n      // Opacity based on distance - improved logic from reference\n      const opacity = currentIndex > index ? 0 : 1;\n\n      // Scale with improved calculation inspired by reference - using clamp function\n      const scale = shouldReduceMotion\n        ? 1\n        : clamp(1 - offsetIndex * SCALE_FACTOR, [MIN_SCALE, MAX_SCALE]);\n\n      // Vertical offset with improved calculation - matching reference exactly\n      const y = shouldReduceMotion\n        ? 0\n        : clamp(offsetIndex * FRAME_OFFSET, [\n            FRAME_OFFSET * FRAMES_VISIBLE_LENGTH,\n            Number.POSITIVE_INFINITY,\n          ]);\n\n      // Z-index for proper layering - matching reference pattern\n      const zIndex = items.length - index;\n\n      return {\n        blur,\n        opacity,\n        scale,\n        y,\n        zIndex,\n      };\n    },\n    [currentIndex, items.length, clamp, shouldReduceMotion]\n  );\n\n  return (\n    <section\n      aria-atomic=\"true\"\n      aria-label=\"Scrollable card stack\"\n      aria-live=\"polite\"\n      className={cn(\"relative mx-auto h-fit w-fit min-w-[300px]\", className)}\n    >\n      {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: Interactive scrollable widget requires event handlers */}\n      <div\n        aria-label=\"Scrollable card container\"\n        className=\"h-full w-full\"\n        onKeyDown={handleKeyDown}\n        onTouchEnd={handleTouchEnd}\n        onTouchMove={handleTouchMove}\n        onTouchStart={handleTouchStart}\n        ref={containerRef}\n        role=\"application\"\n        style={{\n          minHeight: `${cardHeight + CARD_PADDING}px`, // Add some padding for the card stack effect\n          perspective: `${perspective}px`,\n          perspectiveOrigin: \"center 60%\",\n          touchAction: \"none\",\n        }}\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: Required for keyboard navigation\n        tabIndex={0}\n      >\n        {items.map((item, i) => {\n          const transform = getCardTransform(i);\n          const isActive = i === currentIndex;\n          const isHovered = hoveredIndex === i;\n\n          return (\n            <motion.div\n              animate={\n                shouldReduceMotion\n                  ? { x: \"-50%\" }\n                  : {\n                      scale: transform.scale,\n                      x: \"-50%\",\n                      y: `calc(-50% + ${transform.y}px)`,\n                    }\n              }\n              aria-hidden={!isActive}\n              className=\"absolute top-1/2 left-1/2 w-max max-w-[100vw] overflow-hidden rounded-2xl border bg-background shadow-lg\"\n              data-active={isActive}\n              initial={false}\n              key={`scrollable-card-${item.id}`}\n              onBlur={() => setHoveredIndex(null)}\n              onFocus={() => isActive && setHoveredIndex(i)}\n              onMouseEnter={() => isActive && setHoveredIndex(i)}\n              onMouseLeave={() => setHoveredIndex(null)}\n              style={{\n                // Dynamic border width based on scale - from reference code\n                borderWidth: `${2 / transform.scale}px`,\n                filter: `blur(${transform.blur}px)`,\n                height: `${cardHeight}px`,\n                opacity: transform.opacity,\n                pointerEvents: isActive ? \"auto\" : \"none\",\n                transformOrigin: \"center center\",\n                transitionDuration: shouldReduceMotion ? \"0ms\" : \"200ms\",\n                transitionProperty: shouldReduceMotion\n                  ? \"none\"\n                  : \"opacity, filter\",\n                transitionTimingFunction:\n                  \"cubic-bezier(0.645, 0.045, 0.355, 1)\",\n                willChange: shouldReduceMotion\n                  ? undefined\n                  : \"opacity, filter, transform\",\n                zIndex: transform.zIndex,\n              }}\n              tabIndex={isActive ? 0 : -1}\n              transition={\n                shouldReduceMotion\n                  ? { duration: 0 }\n                  : {\n                      damping: 20,\n                      duration: 0.25,\n                      mass: 0.5,\n                      stiffness: 250,\n                      type: \"spring\" as const,\n                    }\n              }\n              whileHover={\n                shouldReduceMotion || !isActive\n                  ? {}\n                  : {\n                      scale: transform.scale * HOVER_SCALE_MULTIPLIER,\n                      transition: {\n                        damping: 20,\n                        duration: 0.25,\n                        mass: 0.5,\n                        stiffness: 250,\n                        type: \"spring\" as const,\n                      },\n                    }\n              }\n            >\n              {/* Card Content */}\n              <div\n                className={cn(\n                  \"flex aspect-16/10 w-full flex-col rounded-xl bg-background transition-all duration-200\",\n                  isHovered && \"shadow-xl\",\n                  isScrolling && isActive && \"ring-2 ring-brand ring-opacity-50\"\n                )}\n                style={{ height: `${cardHeight}px` }}\n              >\n                {/* Scroll indicator */}\n                {isScrolling && isActive ? (\n                  <div className=\"absolute -top-1 left-1/2 h-1 w-8 -translate-x-1/2 rounded-full bg-brand opacity-75\" />\n                ) : null}\n\n                {/* Image Container - takes remaining space */}\n                <div className=\"relative w-full flex-1 overflow-hidden\">\n                  {/* Background blur image */}\n                  <img\n                    alt=\"\"\n                    aria-hidden=\"true\"\n                    className=\"absolute inset-0 h-full w-full object-cover text-transparent\"\n                    decoding=\"async\"\n                    draggable={false}\n                    height={10}\n                    src=\"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIiBmaWxsPSIjZjNmNGY2Ii8+PC9zdmc+\"\n                    style={{\n                      filter: \"blur(32px)\",\n                      pointerEvents: \"none\",\n                      scale: \"1.2\",\n                      zIndex: 1,\n                    }}\n                    width={10}\n                  />\n                  {/* Image */}\n                  <img\n                    alt={`${item.name}'s card`}\n                    className=\"absolute inset-0 h-full w-full object-cover\"\n                    decoding=\"async\"\n                    draggable={false}\n                    height={cardHeight}\n                    src={item.image}\n                    style={{\n                      pointerEvents: \"none\",\n                      userSelect: \"none\",\n                      zIndex: 2,\n                    }}\n                    width={400}\n                  />\n                </div>\n\n                {/* User Info - always at bottom */}\n                <a\n                  aria-label={`View ${item.name}'s profile`}\n                  className={cn(\n                    \"flex items-center justify-center gap-1 bg-background/95 p-3 text-decoration-none text-inherit backdrop-blur-sm transition-colors duration-200\"\n                  )}\n                  href={item.href}\n                  rel=\"noopener noreferrer\"\n                  target=\"_blank\"\n                >\n                  <img\n                    alt={`${item.name}'s avatar`}\n                    className=\"mr-1 h-5 w-5 overflow-hidden rounded-full\"\n                    draggable={false}\n                    height={20}\n                    src={item.avatar}\n                    style={{\n                      boxShadow: \"0 0 0 1px var(--border-secondary, #e0e0e0)\",\n                    }}\n                    width={20}\n                  />\n                  <span className=\"font-medium text-foreground text-sm leading-none\">\n                    {item.name}\n                  </span>\n                  <span className=\"font-normal text-foreground/70 text-sm\">\n                    {item.handle}\n                  </span>\n                </a>\n              </div>\n            </motion.div>\n          );\n        })}\n\n        {/* Navigation indicators */}\n        <div\n          aria-label=\"Card navigation\"\n          className=\"absolute bottom-4 left-1/2 flex -translate-x-1/2 transform space-x-2\"\n          role=\"tablist\"\n        >\n          {Array.from({ length: items.length }, (_, i) => (\n            <motion.button\n              aria-label={`Go to card ${i + 1} of ${items.length}`}\n              aria-selected={i === currentIndex}\n              className={cn(\n                \"h-2 w-2 cursor-pointer rounded-full transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-brand focus:ring-offset-1\",\n                i === currentIndex\n                  ? \"scale-125 bg-brand\"\n                  : \"bg-gray-300 hover:bg-gray-400\"\n              )}\n              key={`scrollable-indicator-${items[i]?.id || i}`}\n              onClick={() => {\n                if (i !== currentIndex && !isScrolling) {\n                  setIsScrolling(true);\n                  setCurrentIndex(i);\n                  scrollY.set(i * SNAP_DISTANCE);\n                  setTimeout(() => {\n                    setIsScrolling(false);\n                  }, transitionDuration + SCROLL_TIMEOUT_OFFSET);\n                }\n              }}\n              role=\"tab\"\n              transition={{\n                damping: 20,\n                mass: 0.5,\n                stiffness: 250,\n                type: \"spring\" as const,\n              }}\n              type=\"button\"\n              whileHover={{ scale: 1.2 }}\n              whileTap={{ scale: 0.9 }}\n            />\n          ))}\n        </div>\n\n        {/* Instructions for screen readers */}\n        <div aria-live=\"polite\" className=\"sr-only\">\n          {`Card ${currentIndex + 1} of ${items.length} selected. Use arrow keys to navigate one card at a time, or click the dots below.`}\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default ScrollableCardStack;\n","path":"index.tsx","target":"components/smoothui/scrollable-card-stack/index.tsx","type":"registry:ui"}],"name":"scrollable-card-stack","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Scrollable Card Stack","type":"registry:ui"}