{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"snap-slides","type":"registry:ui","title":"Snap Slides","description":"A draggable, snapping carousel whose off-center slides tilt away in 3D, with dots, autoplay and arrow-key support.","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"registry/ui/snap-slides.tsx","type":"registry:ui","content":"\"use client\";\n/* eslint-disable @next/next/no-img-element */\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\";\n\nexport interface SnapSlidesItem {\n  image: string;\n  title: string;\n  description?: string;\n}\n\nexport interface SnapSlidesProps {\n  items: SnapSlidesItem[];\n  /** Slide width in px. */\n  itemWidth?: number;\n  /** Slide height in px. */\n  itemHeight?: number;\n  /** Px between slides. */\n  gap?: number;\n  /** Max rotateY of off-center slides in degrees. */\n  tilt?: number;\n  autoplay?: boolean;\n  /** Ms between autoplay steps. */\n  autoplayDelay?: number;\n  /** Wrap around at the ends (arrows, autoplay). */\n  loop?: boolean;\n  /** Slide centered on mount. */\n  startIndex?: number;\n  className?: string;\n}\n\nconst spring = { type: \"spring\" as const, stiffness: 300, damping: 34 };\n\nexport function SnapSlides({\n  items,\n  itemWidth = 300,\n  itemHeight = 340,\n  gap = 24,\n  tilt = 38,\n  autoplay = false,\n  autoplayDelay = 3000,\n  loop = true,\n  startIndex = 0,\n  className,\n}: SnapSlidesProps) {\n  const n = items.length;\n  const stride = itemWidth + gap;\n  const x = useMotionValue(-startIndex * stride);\n  const [index, setIndex] = useState(startIndex);\n  const dragged = useRef(false);\n  const hovered = useRef(false);\n  const reduce = useReducedMotion();\n\n  const go = (i: number) => {\n    const next = loop ? (i + n) % n : Math.max(0, Math.min(n - 1, i));\n    setIndex(next);\n    animate(x, -next * stride, reduce ? { duration: 0 } : spring);\n  };\n\n  // Keep position in sync when the slide size changes.\n  useEffect(() => {\n    x.set(-index * stride);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [stride]);\n\n  useEffect(() => {\n    if (!autoplay || reduce) return;\n    const id = window.setInterval(() => {\n      if (hovered.current) return;\n      setIndex((i) => {\n        const next = (i + 1) % n;\n        animate(x, -next * stride, spring);\n        return next;\n      });\n    }, autoplayDelay);\n    return () => window.clearInterval(id);\n  }, [autoplay, autoplayDelay, n, stride, x, reduce]);\n\n  return (\n    <div\n      className={`flex w-full flex-col items-center gap-6 ${className ?? \"\"}`}\n      onPointerEnter={() => (hovered.current = true)}\n      onPointerLeave={() => (hovered.current = false)}\n    >\n      <div\n        role=\"region\"\n        aria-roledescription=\"carousel\"\n        aria-label=\"Carousel, use arrow keys to navigate\"\n        tabIndex={0}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowRight\") go(index + 1);\n          else if (e.key === \"ArrowLeft\") go(index - 1);\n          else return;\n          e.preventDefault();\n        }}\n        className=\"relative w-full overflow-hidden rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        style={{\n          height: itemHeight + 40,\n          perspective: 1000,\n          maskImage: \"linear-gradient(90deg, transparent, #000 18%, #000 82%, transparent)\",\n          WebkitMaskImage: \"linear-gradient(90deg, transparent, #000 18%, #000 82%, transparent)\",\n        }}\n      >\n        <motion.div\n          className=\"absolute top-5 flex cursor-grab active:cursor-grabbing\"\n          style={{ x, left: \"50%\", marginLeft: -itemWidth / 2, gap, transformStyle: \"preserve-3d\", touchAction: \"pan-y\" }}\n          drag=\"x\"\n          dragConstraints={{ left: -(n - 1) * stride, right: 0 }}\n          dragElastic={0.18}\n          dragMomentum={false}\n          onDragStart={() => (dragged.current = true)}\n          onDragEnd={(_, info) => {\n            const projected = x.get() + info.velocity.x * 0.25;\n            go(Math.max(0, Math.min(n - 1, Math.round(-projected / stride))));\n            window.setTimeout(() => (dragged.current = false));\n          }}\n        >\n          {items.map((item, i) => (\n            <Slide\n              key={item.title + i}\n              item={item}\n              i={i}\n              x={x}\n              stride={stride}\n              tilt={tilt}\n              width={itemWidth}\n              height={itemHeight}\n              active={i === index}\n              onSelect={() => !dragged.current && go(i)}\n            />\n          ))}\n        </motion.div>\n      </div>\n\n      <div className=\"flex items-center gap-2\" role=\"group\" aria-label=\"Choose slide\">\n        {items.map((item, i) => (\n          <button\n            key={item.title + i}\n            type=\"button\"\n            aria-label={`Go to slide ${i + 1}: ${item.title}`}\n            aria-current={i === index || undefined}\n            onClick={() => go(i)}\n            className=\"grid h-6 place-items-center rounded-full px-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <motion.span\n              className=\"block h-2 rounded-full\"\n              initial={false}\n              animate={{\n                width: i === index ? 24 : 8,\n                backgroundColor: i === index ? \"var(--foreground)\" : \"var(--muted-foreground)\",\n                opacity: i === index ? 1 : 0.4,\n              }}\n              transition={spring}\n            />\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\ninterface SlideProps {\n  item: SnapSlidesItem;\n  i: number;\n  x: MotionValue<number>;\n  stride: number;\n  tilt: number;\n  width: number;\n  height: number;\n  active: boolean;\n  onSelect: () => void;\n}\n\nfunction Slide({ item, i, x, stride, tilt, width, height, active, onSelect }: SlideProps) {\n  // Distance from center in slides: 0 = centered, ±1 = neighbours.\n  const d = useTransform(x, (v) => Math.max(-2, Math.min(2, (v + i * stride) / stride)));\n  const rotateY = useTransform(d, (v) => -Math.max(-1, Math.min(1, v)) * tilt);\n  const scale = useTransform(d, (v) => 1 - Math.min(Math.abs(v), 1.5) * 0.1);\n  const opacity = useTransform(d, (v) => 1 - Math.min(Math.abs(v), 2) * 0.25);\n\n  return (\n    <motion.div\n      aria-roledescription=\"slide\"\n      aria-label={item.title}\n      aria-hidden={!active}\n      onClick={onSelect}\n      className=\"flex shrink-0 select-none flex-col overflow-hidden rounded-2xl border border-border bg-card p-3 shadow-[0_25px_50px_-20px_rgba(0,0,0,.7)]\"\n      style={{ width, height, rotateY, scale, opacity }}\n    >\n      <div className=\"relative min-h-0 flex-1 overflow-hidden rounded-xl bg-muted\">\n        <img src={item.image} alt=\"\" draggable={false} className=\"pointer-events-none absolute inset-0 size-full object-cover\" />\n      </div>\n      <div className=\"px-1 pb-1 pt-3\">\n        <p className=\"font-semibold tracking-tight text-foreground\">{item.title}</p>\n        {item.description && <p className=\"mt-0.5 line-clamp-2 text-sm text-muted-foreground\">{item.description}</p>}\n      </div>\n    </motion.div>\n  );\n}\n"}]}