{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swipe-deck","type":"registry:component","title":"Swipe deck","description":"Fling a card away and the rest of the deck steps forward.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"components/swipe-deck.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n// Dragged this far, the card is committed and the deck has fully advanced.\nconst THROW_DISTANCE = 120;\n// A release faster than this (px per second) throws however short the drag.\nconst THROW_VELOCITY = 400;\n// Far enough to clear the deck. The card is fully faded well before this,\n// so it never needs to reach the edge of the screen.\nconst OFFSCREEN = 360;\n// Each card behind sits this much lower and smaller than the one in front.\nconst STEP_Y = 12;\nconst STEP_SCALE = 0.05;\nconst VISIBLE = 3;\n// How cards move up a slot when the deck advances.\nconst STEP_FORWARD = { stiffness: 320, damping: 30 };\n// Both springs start from the finger's release velocity, so a flick keeps\n// exactly the speed the hand gave it instead of restarting from rest.\nconst SNAP_BACK = { type: \"spring\", stiffness: 400, damping: 28 } as const;\n// Slightly overdamped: it carries the throw out without swinging back.\nconst THROW = { type: \"spring\", stiffness: 260, damping: 36 } as const;\n// The drop and the spin settle more lazily than the throw itself. Three\n// springs drifting out of step is what makes it read as a tossed object\n// rather than one scripted path.\nconst DROP = { type: \"spring\", stiffness: 90, damping: 18 } as const;\nconst SPIN = { type: \"spring\", stiffness: 120, damping: 20 } as const;\nconst SHRINK = { duration: 0.3, ease: [0.23, 1, 0.32, 1] } as const;\n\ntype Card = { title: string; note: string };\ntype Flight = {\n  id: number;\n  card: number;\n  from: number;\n  direction: 1 | -1;\n  velocity: number;\n  // Rolled per throw, so no two cards leave the same way.\n  spin: number;\n  drop: number;\n};\n\nfunction rollThrow() {\n  return {\n    spin: 8 + Math.random() * 14,\n    // Mostly falls, as if gravity takes it; now and then it lifts a little.\n    drop: Math.random() * 110 - 20,\n  };\n}\n\n// Pivots from below, like a card held at its bottom edge.\nfunction tiltAt(x: number) {\n  return Math.max(-1, Math.min(x / 240, 1)) * 18;\n}\n\n// Stays solid while you're deciding, then fades on the way out.\nfunction useThrowStyle(x: MotionValue<number>) {\n  const rotate = useTransform(x, tiltAt);\n  const opacity = useTransform(\n    x,\n    [-OFFSCREEN * 0.8, -THROW_DISTANCE, THROW_DISTANCE, OFFSCREEN * 0.8],\n    [0, 1, 1, 0],\n  );\n  return { rotate, opacity };\n}\n\nexport function SwipeDeck({ cards }: { cards: Card[] }) {\n  const reduceMotion = useReducedMotion();\n  const [order, setOrder] = useState(() => cards.map((_, i) => i));\n  const [flights, setFlights] = useState<Flight[]>([]);\n  const nextFlight = useRef(0);\n\n  const x = useMotionValue(0);\n  const { rotate, opacity } = useThrowStyle(x);\n  // Bound to every card that isn't on top. Motion keeps the last value of a\n  // property that stops being passed, so a card that was on top would\n  // otherwise keep its old offset when it cycles back to the front.\n  const still = useMotionValue(0);\n  const opaque = useMotionValue(1);\n  const progress = useTransform(x, (v) =>\n    Math.min(Math.abs(v) / THROW_DISTANCE, 1),\n  );\n  // How far the drag had pulled the deck forward when the card was thrown,\n  // so each card can carry on from where it visibly was.\n  const handoff = useRef(0);\n\n  // The deck advances the moment a card is thrown. The card itself finishes\n  // flying out as a separate copy, so the next one can be grabbed at once\n  // instead of waiting for the throw's spring to settle.\n  const throwCard = (direction: 1 | -1, velocity = 0) => {\n    const flight = {\n      id: nextFlight.current++,\n      card: order[0],\n      from: x.get(),\n      direction,\n      velocity,\n      ...rollThrow(),\n    };\n    handoff.current = progress.get();\n    flushSync(() => {\n      setOrder((o) => [...o.slice(1), o[0]]);\n      if (!reduceMotion) setFlights((f) => [...f, flight]);\n    });\n    x.jump(0);\n  };\n\n  return (\n    <div className=\"flex flex-col items-center gap-8\">\n      <div\n        role=\"group\"\n        aria-roledescription=\"card deck\"\n        aria-label=\"Animation principles\"\n        tabIndex={0}\n        className=\"relative h-80 w-64 rounded-[24px] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-8 focus-visible:outline-foreground\"\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowLeft\") throwCard(-1);\n          if (e.key === \"ArrowRight\") throwCard(1);\n        }}\n      >\n        {order.map((cardIndex, index) => (\n          <DeckCard\n            key={cardIndex}\n            card={cards[cardIndex]}\n            number={cardIndex + 1}\n            index={index}\n            x={index === 0 ? x : still}\n            rotate={index === 0 ? rotate : still}\n            opacity={index === 0 ? opacity : opaque}\n            progress={progress}\n            handoff={handoff}\n            reduceMotion={!!reduceMotion}\n            onRelease={(offset, velocity) => {\n              if (\n                Math.abs(offset) > THROW_DISTANCE ||\n                Math.abs(velocity) > THROW_VELOCITY\n              ) {\n                throwCard(offset < 0 ? -1 : 1, velocity);\n              } else {\n                animate(x, 0, { ...SNAP_BACK, velocity });\n              }\n            }}\n          />\n        ))}\n        {flights.map((flight) => (\n          <FlyingCard\n            key={flight.id}\n            flight={flight}\n            card={cards[flight.card]}\n            onLanded={() =>\n              setFlights((f) => f.filter((other) => other.id !== flight.id))\n            }\n          />\n        ))}\n      </div>\n      <div className=\"flex gap-3\">\n        <DeckButton label=\"Throw left\" onClick={() => throwCard(-1)}>\n          <path d=\"M13 8H3M7 4 3 8l4 4\" />\n        </DeckButton>\n        <DeckButton label=\"Throw right\" onClick={() => throwCard(1)}>\n          <path d=\"M3 8h10M9 4l4 4-4 4\" />\n        </DeckButton>\n      </div>\n    </div>\n  );\n}\n\nfunction DeckCard({\n  card,\n  number,\n  index,\n  x,\n  rotate,\n  opacity,\n  progress,\n  handoff,\n  reduceMotion,\n  onRelease,\n}: {\n  card: Card;\n  number: number;\n  index: number;\n  x: MotionValue<number>;\n  rotate: MotionValue<number>;\n  opacity: MotionValue<number>;\n  progress: MotionValue<number>;\n  handoff: React.RefObject<number>;\n  reduceMotion: boolean;\n  onRelease: (offset: number, velocity: number) => void;\n}) {\n  const top = index === 0;\n  // The slot this card is settling into. Springs when the deck advances\n  // instead of snapping, which matters most for button throws with no drag.\n  const slot = useSpring(index, STEP_FORWARD);\n  const previous = useRef(index);\n\n  // Runs inside the throw's flushSync, before the drag offset resets.\n  useLayoutEffect(() => {\n    const from = previous.current;\n    previous.current = index;\n    if (from === index) return;\n    if (reduceMotion) {\n      slot.jump(index);\n      return;\n    }\n    if (from >= VISIBLE && index < VISIBLE) {\n      // Coming into view: start a step further back and rise into place,\n      // like the next card being slid onto the deck.\n      slot.jump(index + 1);\n    } else {\n      // Carry on from where the drag had visibly pulled it.\n      slot.jump(slot.get() - handoff.current);\n    }\n    slot.set(index);\n  }, [index, reduceMotion, slot, handoff]);\n\n  // Every card behind also moves up as the top card is dragged away, so when\n  // it leaves, the next one is already exactly in place.\n  const depth = useTransform(() => Math.max(slot.get() - progress.get(), 0));\n  const y = useTransform(depth, (d) => d * STEP_Y);\n  const scale = useTransform(depth, (d) => 1 - d * STEP_SCALE);\n\n  return (\n    <motion.article\n      aria-hidden={!top}\n      drag={top ? \"x\" : false}\n      // Motion coasts on its own after release by default, which would fight\n      // the throw and snap-back springs for the same value.\n      dragMomentum={false}\n      onDragEnd={(_, info) => onRelease(info.offset.x, info.velocity.x)}\n      style={{ x, rotate, y, scale, zIndex: 100 - index }}\n      // Fades in when it moves up into view, but leaves instantly: the thrown\n      // card lands at the back, and a fade there would peek out below the deck.\n      className=\"absolute inset-0 origin-bottom cursor-grab touch-pan-y transition-[opacity] duration-300 ease-out select-none active:cursor-grabbing data-[hidden=true]:opacity-0 data-[hidden=true]:transition-none\"\n      data-hidden={index >= VISIBLE}\n    >\n      <CardFace card={card} number={number} opacity={opacity} />\n    </motion.article>\n  );\n}\n\nfunction FlyingCard({\n  flight,\n  card,\n  onLanded,\n}: {\n  flight: Flight;\n  card: Card;\n  onLanded: () => void;\n}) {\n  const x = useMotionValue(flight.from);\n  const y = useMotionValue(0);\n  // Starts from exactly the tilt it had when released, then spins on.\n  const rotate = useMotionValue(tiltAt(flight.from));\n  const scale = useMotionValue(1);\n  const { opacity } = useThrowStyle(x);\n  const landed = useRef(onLanded);\n\n  useEffect(() => {\n    landed.current = onLanded;\n  });\n\n  useEffect(() => {\n    const out = animate(x, flight.direction * OFFSCREEN, {\n      ...THROW,\n      velocity: flight.velocity,\n    });\n    const rest = [\n      animate(y, flight.drop, DROP),\n      animate(rotate, rotate.get() + flight.direction * flight.spin, SPIN),\n      animate(scale, 0.94, SHRINK),\n    ];\n    // Gone once it has faded out sideways; the lazier springs needn't finish.\n    out.then(() => landed.current());\n    return () => [out, ...rest].forEach((c) => c.stop());\n  }, [x, y, rotate, scale, flight]);\n\n  return (\n    <motion.div\n      aria-hidden\n      style={{ x, y, rotate, scale, zIndex: 200 }}\n      className=\"pointer-events-none absolute inset-0 origin-bottom\"\n    >\n      <CardFace card={card} number={flight.card + 1} opacity={opacity} />\n    </motion.div>\n  );\n}\n\nfunction CardFace({\n  card,\n  number,\n  opacity,\n}: {\n  card: Card;\n  number: number;\n  opacity: MotionValue<number>;\n}) {\n  return (\n    <motion.div\n      style={{ opacity }}\n      className=\"flex h-full flex-col justify-between rounded-[24px] bg-background p-6 shadow-raised\"\n    >\n      <span className=\"font-mono text-xs text-muted-foreground tabular-nums\">\n        {String(number).padStart(2, \"0\")}\n      </span>\n      <div>\n        <p className=\"text-xl font-medium tracking-tight text-balance\">\n          {card.title}\n        </p>\n        <p className=\"mt-2 text-sm text-pretty text-muted-foreground\">{card.note}</p>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction DeckButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className=\"flex size-11 items-center justify-center rounded-full bg-muted text-foreground shadow-raised transition-[scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-none\"\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nconst CARDS: Card[] = [\n  {\n    title: \"Exits are quicker than entrances.\",\n    note: \"Arrive with care, leave out of the way.\",\n  },\n  {\n    title: \"Animate transform and opacity.\",\n    note: \"Layout properties make the browser redo work every frame.\",\n  },\n  {\n    title: \"Skip what people do a hundred times a day.\",\n    note: \"Frequent actions should feel instant, not animated.\",\n  },\n  {\n    title: \"A quick flick should be enough.\",\n    note: \"Judge a gesture by its speed, not only its distance.\",\n  },\n  {\n    title: \"Nothing appears from scale(0).\",\n    note: \"Start near full size and fade in instead.\",\n  },\n];\n\nexport default function SwipeDeckDemo() {\n  return <SwipeDeck cards={CARDS} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/swipe-deck"}