{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"deck","type":"registry:ui","title":"deck","description":"A Tinder-like swipeable card stack component with smooth animations.","author":"Hayden Bleasel <hello@haydenbleasel.com>","dependencies":["@radix-ui/react-use-controllable-state","lucide-react","motion"],"devDependencies":[],"registryDependencies":[],"files":[{"type":"registry:ui","path":"index.tsx","content":"\"use client\";\n\nimport { useControllableState } from \"@radix-ui/react-use-controllable-state\";\nimport {\n  motion,\n  type PanInfo,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport {\n  Children,\n  cloneElement,\n  type HTMLAttributes,\n  type ReactElement,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DeckProps = HTMLAttributes<HTMLDivElement>;\n\nexport const Deck = ({ className, ...props }: DeckProps) => (\n  <div className={cn(\"relative isolate\", className)} {...props} />\n);\n\nexport type DeckCardsProps = HTMLAttributes<HTMLDivElement> & {\n  onSwipe?: (index: number, direction: \"left\" | \"right\") => void;\n  onSwipeEnd?: (index: number, direction: \"left\" | \"right\") => void;\n  threshold?: number;\n  stackSize?: number;\n  perspective?: number;\n  scale?: number;\n  currentIndex?: number;\n  defaultCurrentIndex?: number;\n  onCurrentIndexChange?: (index: number) => void;\n  animateOnIndexChange?: boolean;\n  indexChangeDirection?: \"left\" | \"right\";\n};\n\nexport const DeckCards = ({\n  children,\n  className,\n  onSwipe,\n  onSwipeEnd,\n  threshold = 150,\n  stackSize = 3,\n  perspective = 1000,\n  scale = 0.05,\n  currentIndex: currentIndexProp,\n  defaultCurrentIndex = 0,\n  onCurrentIndexChange,\n  animateOnIndexChange = true,\n  indexChangeDirection = \"left\",\n  ...props\n}: DeckCardsProps) => {\n  const childrenArray = Children.toArray(children) as ReactElement[];\n  const [currentIndex, setCurrentIndex] = useControllableState({\n    prop: currentIndexProp,\n    defaultProp: defaultCurrentIndex,\n    onChange: onCurrentIndexChange,\n  });\n  const [exitDirection, setExitDirection] = useState<\"left\" | \"right\" | null>(\n    null\n  );\n  const [displayIndex, setDisplayIndex] = useState(currentIndex);\n  const isInternalChangeRef = useRef(false);\n  const prevIndexRef = useRef(currentIndex);\n\n  // Detect external currentIndex changes and trigger animation\n  useEffect(() => {\n    const prevIndex = prevIndexRef.current;\n\n    // Skip initial mount and internal changes\n    if (prevIndex === currentIndex || isInternalChangeRef.current) {\n      isInternalChangeRef.current = false;\n      prevIndexRef.current = currentIndex;\n      setDisplayIndex(currentIndex);\n      return;\n    }\n\n    // Only animate if the option is enabled and we have cards to show\n    if (animateOnIndexChange && prevIndex < childrenArray.length) {\n      setExitDirection(indexChangeDirection);\n\n      // Update display index after animation completes\n      setTimeout(() => {\n        setExitDirection(null);\n        setDisplayIndex(currentIndex);\n      }, 300);\n    } else {\n      // No animation, update display index immediately\n      setDisplayIndex(currentIndex);\n    }\n\n    prevIndexRef.current = currentIndex;\n  }, [\n    currentIndex,\n    animateOnIndexChange,\n    indexChangeDirection,\n    childrenArray.length,\n  ]);\n\n  const handleSwipe = useCallback(\n    (direction: \"left\" | \"right\") => {\n      if (displayIndex >= childrenArray.length) {\n        return;\n      }\n\n      setExitDirection(direction);\n\n      if (direction === \"left\") {\n        onSwipe?.(displayIndex, \"left\");\n      } else {\n        onSwipe?.(displayIndex, \"right\");\n      }\n\n      onSwipeEnd?.(displayIndex, direction);\n\n      // Move to next card after animation\n      setTimeout(() => {\n        isInternalChangeRef.current = true;\n        const newIndex = displayIndex + 1;\n        setCurrentIndex(newIndex);\n        setDisplayIndex(newIndex);\n        setExitDirection(null);\n      }, 300);\n    },\n    [displayIndex, childrenArray.length, onSwipe, onSwipeEnd, setCurrentIndex]\n  );\n\n  const visibleCards = childrenArray.slice(\n    displayIndex,\n    displayIndex + stackSize\n  );\n\n  if (displayIndex >= childrenArray.length) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\"relative z-10 size-full\", className)}\n      style={{ perspective }}\n      {...props}\n    >\n      {visibleCards.map((child, index) => {\n        const isTopCard = !index;\n        const zIndex = stackSize - index;\n        const scaleValue = 1 - index * scale;\n        const yOffset = index * 4;\n        const cardKey = `${displayIndex}-${child.key ?? index}`;\n\n        if (isTopCard) {\n          return (\n            <DeckCard\n              exitDirection={exitDirection}\n              key={cardKey}\n              onSwipe={handleSwipe}\n              style={{\n                zIndex,\n                scale: scaleValue,\n                y: yOffset,\n              }}\n              threshold={threshold}\n            >\n              {child}\n            </DeckCard>\n          );\n        }\n\n        const nextCardScale = index === 1 && exitDirection ? 1 : scaleValue;\n        const nextCardY = index === 1 && exitDirection ? 0 : yOffset;\n\n        return (\n          <motion.div\n            animate={{\n              scale: nextCardScale,\n              y: nextCardY,\n            }}\n            className=\"absolute inset-0\"\n            key={cardKey}\n            style={{\n              zIndex,\n              scale: scaleValue,\n              y: yOffset,\n            }}\n            transition={{ duration: 0.3, ease: \"easeOut\" }}\n          >\n            {child}\n          </motion.div>\n        );\n      })}\n    </div>\n  );\n};\n\ntype DeckCardProps = {\n  children: ReactElement;\n  onSwipe: (direction: \"left\" | \"right\") => void;\n  threshold: number;\n  style?: object;\n  exitDirection: \"left\" | \"right\" | null;\n};\n\nconst DeckCard = ({\n  children,\n  onSwipe,\n  threshold,\n  style,\n  exitDirection,\n}: DeckCardProps) => {\n  const x = useMotionValue(0);\n  const rotate = useTransform(x, [-200, 200], [-25, 25]);\n  const opacity = useTransform(\n    x,\n    [-200, -threshold, 0, threshold, 200],\n    [0, 1, 1, 1, 0]\n  );\n\n  const handleDragEnd = (_: unknown, info: PanInfo) => {\n    const swipeThreshold = threshold;\n\n    if (Math.abs(info.offset.x) > swipeThreshold) {\n      const direction = info.offset.x > 0 ? \"right\" : \"left\";\n      onSwipe(direction);\n    }\n  };\n\n  let exitX = 0;\n\n  if (exitDirection === \"left\") {\n    exitX = -500;\n  } else if (exitDirection === \"right\") {\n    exitX = 500;\n  }\n\n  const castedChildren = children as ReactElement<\n    HTMLAttributes<HTMLDivElement>\n  >;\n\n  return (\n    <motion.div\n      animate={exitDirection ? { x: exitX, opacity: 0 } : undefined}\n      className=\"absolute inset-0 cursor-grab active:cursor-grabbing\"\n      drag=\"x\"\n      dragConstraints={{ left: 0, right: 0 }}\n      onDragEnd={handleDragEnd}\n      style={{\n        x,\n        rotate,\n        opacity,\n        ...style,\n      }}\n      transition={{ duration: 0.3, ease: \"easeOut\" }}\n      whileDrag={{ scale: 1.05 }}\n    >\n      {cloneElement(castedChildren, {\n        className: cn(\n          \"h-full w-full select-none rounded-lg shadow-lg\",\n          castedChildren.props.className\n        ),\n      })}\n    </motion.div>\n  );\n};\n\nexport type DeckItemProps = HTMLAttributes<HTMLDivElement>;\n\nexport const DeckItem = ({ className, ...props }: DeckItemProps) => (\n  <div\n    className={cn(\n      \"flex h-full w-full items-center justify-center rounded-lg border bg-card text-card-foreground shadow-lg\",\n      className\n    )}\n    {...props}\n  />\n);\n\nexport type DeckEmptyProps = HTMLAttributes<HTMLDivElement>;\n\nexport const DeckEmpty = ({\n  children,\n  className,\n  ...props\n}: HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"absolute inset-0 flex items-center justify-center rounded-lg border border-dashed text-muted-foreground\",\n      className\n    )}\n    {...props}\n  >\n    {children ?? <p className=\"text-sm\">No more cards</p>}\n  </div>\n);\n","target":"components/kibo-ui/deck/index.tsx"}],"css":{}}