{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A CardSwipeDeck component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  animate,\n  motion,\n  type PanInfo,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useState,\n} from \"react\";\n\nconst DEFAULT_THRESHOLD = 110; // px of horizontal drag needed to commit\nconst DEFAULT_ROTATION_FACTOR = 18; // deg at the edge of DRAG_RANGE\nconst DEFAULT_STACK_SIZE = 3;\nconst DEFAULT_STACK_OFFSET = 14; // px per receding card\nconst STACK_SCALE_STEP = 0.05;\nconst DRAG_RANGE = 260; // px input domain for the rotation transform\nconst VELOCITY_COMMIT_THRESHOLD = 450; // px/s\nconst EXIT_DISTANCE = 900; // px travelled before unmount\nconst STAMP_RATIO = 0.85; // stamps hit full strength just before the threshold\nconst WASH_RATIO = 1.35; // the colour wash lags the stamps slightly\nconst WASH_MAX_OPACITY = 0.9;\nconst MIN_THROW_SPEED = 750; // px/s floor so button swipes still fly\nconst THROW_DURATION = 0.34;\nconst FADE_DELAY = 0.2;\nconst FADE_DURATION = 0.14;\nconst VERTICAL_LIMIT = 90; // px of vertical give while dragging\nconst STAMP_MIN_SCALE = 0.7;\nconst DEFAULT_THROW_LIFT = -160; // px/s upward bias for non-drag commits\n/**\n * Rotation pivot sits *below* the bottom edge of the card. This is the detail\n * that makes the Tinder tilt read correctly: the card swings around a point\n * near the user's hand rather than around its own centre.\n */\nconst PIVOT_ORIGIN = \"50% 165%\";\nconst THROW_TRANSITION = {\n  bounce: 0,\n  duration: THROW_DURATION,\n  type: \"spring\" as const,\n};\nconst SNAP_BACK_TRANSITION = {\n  bounce: 0.3,\n  duration: 0.45,\n  type: \"spring\" as const,\n};\nconst STACK_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst FADE_EASE = [0.645, 0.045, 0.355, 1] as const;\n\nexport type SwipeDirection = \"left\" | \"right\";\n\nexport interface CardSwipeDeckItem {\n  content: ReactNode;\n  id: string;\n}\n\nexport interface CardSwipeDeckLabels {\n  left?: string;\n  right?: string;\n}\n\nexport interface CardSwipeDeckHandle {\n  reset: () => void;\n  swipeLeft: () => void;\n  swipeRight: () => void;\n}\n\nexport interface CardSwipeDeckProps {\n  className?: string;\n  disabled?: boolean;\n  items: CardSwipeDeckItem[];\n  /** Stamp copy burned into the top corners, e.g. `{ left: \"Nope\", right: \"Like\" }`. */\n  labels?: CardSwipeDeckLabels;\n  onEmpty?: () => void;\n  onSwipe?: (id: string, direction: SwipeDirection) => void;\n  ref?: Ref<CardSwipeDeckHandle>;\n  /** Max rotation (degrees) applied at the edge of the drag range. */\n  rotationFactor?: number;\n  /** Vertical offset (px) applied to each receding card in the stack. */\n  stackOffset?: number;\n  /** How many cards are visible in the stack, front card included. */\n  stackSize?: number;\n  /** Drag distance (px) required to commit a swipe. */\n  threshold?: number;\n}\n\ninterface ThrowVector {\n  direction: SwipeDirection;\n  vx: number;\n  vy: number;\n}\n\ninterface DeckCardProps {\n  disabled: boolean;\n  isFront: boolean;\n  item: CardSwipeDeckItem;\n  labels: Required<CardSwipeDeckLabels>;\n  onCommit: (direction: SwipeDirection, info?: PanInfo) => void;\n  onExitComplete: () => void;\n  pos: number;\n  rotationFactor: number;\n  shouldReduceMotion: boolean;\n  stackOffset: number;\n  stackSize: number;\n  threshold: number;\n  throwVector: ThrowVector | null;\n  zIndex: number;\n}\n\nconst DeckCard = ({\n  disabled,\n  isFront,\n  item,\n  labels,\n  onCommit,\n  onExitComplete,\n  pos,\n  rotationFactor,\n  shouldReduceMotion,\n  stackOffset,\n  stackSize,\n  threshold,\n  throwVector,\n  zIndex,\n}: DeckCardProps) => {\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const isExiting = throwVector !== null;\n\n  const rotate = useTransform(\n    x,\n    [-DRAG_RANGE, DRAG_RANGE],\n    [-rotationFactor, rotationFactor]\n  );\n\n  const stampSpan = threshold * STAMP_RATIO;\n  const washSpan = threshold * WASH_RATIO;\n\n  // Stamps and wash are driven straight off the drag distance, so they bloom\n  // continuously under the finger instead of snapping in at the threshold.\n  const likeOpacity = useTransform(x, [0, stampSpan], [0, 1]);\n  const likeScale = useTransform(x, [0, stampSpan], [STAMP_MIN_SCALE, 1]);\n  const nopeOpacity = useTransform(x, [-stampSpan, 0], [1, 0]);\n  const nopeScale = useTransform(x, [-stampSpan, 0], [1, STAMP_MIN_SCALE]);\n  const likeWash = useTransform(x, [0, washSpan], [0, WASH_MAX_OPACITY]);\n  const nopeWash = useTransform(x, [-washSpan, 0], [WASH_MAX_OPACITY, 0]);\n\n  useEffect(() => {\n    if (!throwVector || shouldReduceMotion) {\n      return;\n    }\n    const targetX =\n      throwVector.direction === \"left\" ? -EXIT_DISTANCE : EXIT_DISTANCE;\n    const speed = Math.max(Math.abs(throwVector.vx), MIN_THROW_SPEED);\n    const signedVx = throwVector.direction === \"left\" ? -speed : speed;\n    // Keep the throw on the trajectory the finger was already describing.\n    const targetY = y.get() + throwVector.vy * THROW_DURATION;\n\n    const xControls = animate(x, targetX, {\n      ...THROW_TRANSITION,\n      velocity: signedVx,\n    });\n    const yControls = animate(y, targetY, {\n      ...THROW_TRANSITION,\n      velocity: throwVector.vy,\n    });\n\n    return () => {\n      xControls.stop();\n      yControls.stop();\n    };\n  }, [throwVector, shouldReduceMotion, x, y]);\n\n  const handleDragEnd = useCallback(\n    (_event: unknown, info: PanInfo) => {\n      if (disabled) {\n        return;\n      }\n      const shouldCommit =\n        Math.abs(info.offset.x) > threshold ||\n        Math.abs(info.velocity.x) > VELOCITY_COMMIT_THRESHOLD;\n\n      if (shouldCommit) {\n        onCommit(info.offset.x > 0 ? \"right\" : \"left\", info);\n        return;\n      }\n      // Below threshold: elastic snap back to centre.\n      animate(x, 0, SNAP_BACK_TRANSITION);\n      animate(y, 0, SNAP_BACK_TRANSITION);\n    },\n    [disabled, onCommit, threshold, x, y]\n  );\n\n  const isBuried = pos >= stackSize;\n  const canDrag = isFront && !disabled && !isExiting;\n\n  return (\n    <motion.div\n      animate={{\n        scale: 1 - pos * STACK_SCALE_STEP,\n        y: pos * stackOffset,\n      }}\n      className=\"absolute inset-0\"\n      initial={false}\n      style={{ zIndex }}\n      transition={shouldReduceMotion ? { duration: 0 } : STACK_TRANSITION}\n    >\n      <motion.div\n        animate={\n          isExiting\n            ? {\n                opacity: 0,\n                transition: shouldReduceMotion\n                  ? { duration: 0 }\n                  : {\n                      delay: FADE_DELAY,\n                      duration: FADE_DURATION,\n                      ease: FADE_EASE,\n                    },\n              }\n            : { opacity: isBuried ? 0 : 1 }\n        }\n        className=\"relative h-full w-full cursor-grab overflow-hidden rounded-[28px] bg-background shadow-[0_1px_2px_rgba(0,0,0,0.06),0_10px_20px_-8px_rgba(0,0,0,0.18),0_28px_56px_-20px_rgba(0,0,0,0.32)] ring-1 ring-foreground/10 active:cursor-grabbing\"\n        drag={canDrag}\n        initial={false}\n        dragConstraints={{ bottom: VERTICAL_LIMIT, top: -VERTICAL_LIMIT }}\n        dragElastic={{ bottom: 0.35, left: 1, right: 1, top: 0.35 }}\n        dragMomentum={false}\n        onAnimationComplete={() => {\n          if (isExiting) {\n            onExitComplete();\n          }\n        }}\n        onDragEnd={handleDragEnd}\n        style={{\n          rotate,\n          transformOrigin: PIVOT_ORIGIN,\n          x,\n          y,\n        }}\n        transition={shouldReduceMotion ? { duration: 0 } : STACK_TRANSITION}\n        whileDrag={shouldReduceMotion ? undefined : { scale: 1.02 }}\n      >\n        {item.content}\n\n        {/* Kept mounted through the throw so the stamp flies off with the card. */}\n        {isFront ? (\n          <>\n            {/* Colour wash — the card itself tints toward the outcome. */}\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 bg-gradient-to-l from-green/55 via-green/15 to-transparent\"\n              style={{ opacity: likeWash }}\n            />\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-destructive/55 via-destructive/15 to-transparent\"\n              style={{ opacity: nopeWash }}\n            />\n\n            {/* Stamps — outlined, rotated, driven by drag distance. */}\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-7 left-6 rounded-xl border-[3px] border-green px-3 py-1 font-black text-2xl text-green uppercase leading-none tracking-[0.08em] drop-shadow-sm\"\n              style={{\n                opacity: likeOpacity,\n                rotate: -20,\n                scale: likeScale,\n              }}\n            >\n              {labels.right}\n            </motion.div>\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute top-7 right-6 rounded-xl border-[3px] border-destructive px-3 py-1 font-black text-2xl text-destructive uppercase leading-none tracking-[0.08em] drop-shadow-sm\"\n              style={{\n                opacity: nopeOpacity,\n                rotate: 20,\n                scale: nopeScale,\n              }}\n            >\n              {labels.left}\n            </motion.div>\n          </>\n        ) : null}\n      </motion.div>\n    </motion.div>\n  );\n};\n\nexport default function CardSwipeDeck({\n  className,\n  disabled = false,\n  items,\n  labels,\n  onEmpty,\n  onSwipe,\n  ref,\n  rotationFactor = DEFAULT_ROTATION_FACTOR,\n  stackOffset = DEFAULT_STACK_OFFSET,\n  stackSize = DEFAULT_STACK_SIZE,\n  threshold = DEFAULT_THRESHOLD,\n}: CardSwipeDeckProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [removedIds, setRemovedIds] = useState<Set<string>>(new Set());\n  const [exiting, setExiting] = useState<{\n    id: string;\n    vector: ThrowVector;\n  } | null>(null);\n\n  const remaining = items.filter((item) => !removedIds.has(item.id));\n  // One extra card is mounted so the stack keeps its depth while the top\n  // card is flying away.\n  const visible = remaining.slice(0, stackSize + 1);\n\n  const commitSwipe = useCallback(\n    (direction: SwipeDirection, info?: PanInfo) => {\n      if (disabled || exiting) {\n        return;\n      }\n      const [top] = remaining;\n      if (!top) {\n        return;\n      }\n      onSwipe?.(top.id, direction);\n      const wasLast = remaining.length === 1;\n\n      if (shouldReduceMotion) {\n        setRemovedIds((prev) => new Set(prev).add(top.id));\n      } else {\n        setExiting({\n          id: top.id,\n          vector: {\n            direction,\n            vx: info?.velocity.x ?? 0,\n            vy: info?.velocity.y ?? DEFAULT_THROW_LIFT,\n          },\n        });\n      }\n      if (wasLast) {\n        onEmpty?.();\n      }\n    },\n    [disabled, exiting, onEmpty, onSwipe, remaining, shouldReduceMotion]\n  );\n\n  const handleExitComplete = useCallback((id: string) => {\n    setRemovedIds((prev) => new Set(prev).add(id));\n    setExiting(null);\n  }, []);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      reset: () => {\n        setRemovedIds(new Set());\n        setExiting(null);\n      },\n      swipeLeft: () => commitSwipe(\"left\"),\n      swipeRight: () => commitSwipe(\"right\"),\n    }),\n    [commitSwipe]\n  );\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) {\n      return;\n    }\n    if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      commitSwipe(\"left\");\n    } else if (event.key === \"ArrowRight\" || event.key === \"Enter\") {\n      event.preventDefault();\n      commitSwipe(\"right\");\n    }\n  };\n\n  const resolvedLabels: Required<CardSwipeDeckLabels> = {\n    left: labels?.left ?? \"Nope\",\n    right: labels?.right ?? \"Like\",\n  };\n\n  return (\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Interactive swipeable widget requires event handlers\n    <div\n      aria-label=\"Swipeable card deck\"\n      className={cn(\n        \"relative h-[480px] w-[340px] max-w-full touch-pan-y outline-none\",\n        className\n      )}\n      onKeyDown={handleKeyDown}\n      role=\"application\"\n      // biome-ignore lint/a11y/noNoninteractiveTabindex: Required for keyboard navigation\n      tabIndex={0}\n    >\n      {visible.length === 0 ? (\n        <div className=\"flex h-full w-full items-center justify-center rounded-[28px] border border-foreground/10 border-dashed\">\n          <p className=\"text-center text-muted-foreground text-sm\">\n            No more cards\n          </p>\n        </div>\n      ) : (\n        visible.map((item, index) => {\n          const isExitingCard = exiting?.id === item.id;\n          // While the top card leaves, everything behind it slides forward\n          // one slot — the next card scales up as the throw happens.\n          const pos = exiting ? Math.max(index - 1, 0) : index;\n\n          return (\n            <DeckCard\n              disabled={disabled}\n              isFront={index === 0}\n              item={item}\n              key={item.id}\n              labels={resolvedLabels}\n              onCommit={commitSwipe}\n              onExitComplete={() => handleExitComplete(item.id)}\n              pos={isExitingCard ? 0 : pos}\n              rotationFactor={rotationFactor}\n              shouldReduceMotion={Boolean(shouldReduceMotion)}\n              stackOffset={stackOffset}\n              stackSize={stackSize}\n              threshold={threshold}\n              throwVector={isExitingCard ? exiting.vector : null}\n              zIndex={visible.length - index}\n            />\n          );\n        })\n      )}\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {remaining.length > 0\n          ? `${remaining.length} card${remaining.length === 1 ? \"\" : \"s\"} remaining`\n          : \"No cards remaining\"}\n      </div>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/card-swipe-deck/index.tsx","type":"registry:ui"}],"name":"card-swipe-deck","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Card Swipe Deck","type":"registry:ui"}