{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-slider",
  "title": "Image Slider",
  "description": "Full-bleed image slider with crossfade-zoom, autoplay, dots, arrows and swipe.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/image-slider.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst noopSubscribe = () => () => {};\n\nconst Icon = ({ d }: { d: string }) => (\n  <svg aria-hidden viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" className=\"size-4\">\n    <path d={d} />\n  </svg>\n);\n\nexport interface SliderImage {\n  /** Image URL */\n  src: string;\n  /** Alternative text (\"\" when the image is purely decorative) */\n  alt: string;\n}\n\ninterface ImageSliderProps extends React.HTMLAttributes<HTMLElement> {\n  /** Images to cycle through */\n  images: SliderImage[];\n  /** Content layered above the images (headline, call-to-action…) */\n  children?: React.ReactNode;\n  /** Advance automatically (never starts on its own under reduced motion) */\n  autoplay?: boolean;\n  /** ms each slide stays on screen while autoplaying */\n  interval?: number;\n  /** Darken the images so overlay text and controls stay legible */\n  scrim?: boolean;\n  /** Accessible name for the carousel region */\n  label?: string;\n}\n\nconst button =\n  \"pointer-events-auto grid size-9 place-items-center rounded-full bg-black/35 text-white backdrop-blur-sm hover:bg-black/55 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white\";\n\n/**\n * Full-bleed crossfading image slider with autoplay, dots, arrows, arrow\n * keys and swipe; children are layered above the images. Autoplay pauses on\n * hover/focus and offscreen, and never starts itself under reduced motion.\n */\nexport function ImageSlider({\n  images,\n  children,\n  autoplay = true,\n  interval = 5000,\n  scrim = true,\n  label = \"Image slider\",\n  className,\n  ...props\n}: ImageSliderProps) {\n  const ref = useRef<HTMLElement>(null);\n  const startX = useRef<number | null>(null);\n  const hydrated = useSyncExternalStore(noopSubscribe, () => true, () => false);\n  const reduced = !!useReducedMotion() && hydrated;\n  const [index, setIndex] = useState(0);\n  const [choice, setChoice] = useState<boolean | null>(null);\n  // Bits: 1 hovered, 2 focused\n  const [held, setHeld] = useState(0);\n  const [visible, setVisible] = useState(true);\n  const n = images.length;\n  const playing = choice ?? (autoplay && !reduced);\n  const running = playing && !held && visible && n > 1;\n\n  const go = (delta: number) => setIndex((i) => (i + delta + n) % n);\n\n  // Offscreen or in a hidden tab: stop\n  useEffect(() => {\n    let inView = true;\n    const update = () => setVisible(inView && !document.hidden);\n    const observer = new IntersectionObserver(([e]) => {\n      inView = e.isIntersecting;\n      update();\n    });\n    observer.observe(ref.current!);\n    document.addEventListener(\"visibilitychange\", update);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", update);\n    };\n  }, []);\n\n  // Re-armed on every slide change, so manual navigation resets it\n  useEffect(() => {\n    if (!running) return;\n    const timer = setTimeout(() => setIndex((index + 1) % n), interval);\n    return () => clearTimeout(timer);\n  }, [running, index, interval, n]);\n\n  // Preload the next image\n  useEffect(() => {\n    if (n > 1) new Image().src = images[(index + 1) % n].src;\n  }, [index, images, n]);\n\n  const image = images[index];\n\n  return (\n    <section\n      {...props}\n      ref={ref}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      data-slot=\"image-slider\"\n      className={cn(\"relative isolate h-96 w-full touch-pan-y overflow-hidden bg-muted text-white\", className)}\n    >\n      {/* display: contents — leaves the consumer's handlers on <section> alone */}\n      <div\n        className=\"contents\"\n        onMouseEnter={() => setHeld((h) => h | 1)}\n        onMouseLeave={() => setHeld((h) => h & 2)}\n        onFocus={() => setHeld((h) => h | 2)}\n        onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setHeld((h) => h & 1)}\n        onKeyDown={(e) => {\n          const k = [\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].indexOf(e.key);\n          if (n < 2 || k < 0 || (e.target as HTMLElement).matches(\"input,textarea,select\")) return;\n          e.preventDefault();\n          if (k < 2) go(k * 2 - 1);\n          else setIndex(k > 2 ? n - 1 : 0);\n        }}\n        onPointerDown={(e) => {\n          if (e.pointerType !== \"mouse\") startX.current = e.clientX;\n        }}\n        onPointerUp={(e) => {\n          const dx = e.clientX - (startX.current ?? e.clientX);\n          startX.current = null;\n          if (n > 1 && Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);\n        }}\n      >\n        {n > 1 && (\n          <button\n            type=\"button\"\n            aria-label={playing ? \"Pause slideshow\" : \"Play slideshow\"}\n            onClick={() => setChoice(!playing)}\n            className={cn(button, \"absolute right-4 bottom-4 z-20\")}\n          >\n            <Icon d={playing ? \"M9 5v14M15 5v14\" : \"M7 4.5v15L19.5 12Z\"} />\n          </button>\n        )}\n\n        <div aria-live={running ? \"off\" : \"polite\"} className=\"absolute inset-0\">\n          <AnimatePresence initial={false}>\n            {image && (\n              <motion.div\n                key={index}\n                role=\"group\"\n                aria-roledescription=\"slide\"\n                aria-label={`${index + 1} of ${n}`}\n                initial={{ opacity: 0, scale: 1.08 }}\n                animate={{ opacity: 1, scale: 1, zIndex: 1 }}\n                // The old slide stays opaque underneath until the new one has faded in\n                exit={{ opacity: 0, zIndex: 0, transition: { duration: 0, delay: reduced ? 0 : 0.9 } }}\n                transition={\n                  reduced ? { duration: 0 } : { duration: 0.9, ease: \"easeOut\", scale: { duration: 1.8, ease: [0.2, 0.7, 0.2, 1] } }\n                }\n                className=\"absolute inset-0\"\n              >\n                {/* eslint-disable-next-line @next/next/no-img-element */}\n                <img src={image.src} alt={image.alt} draggable={false} className=\"size-full object-cover\" />\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        {scrim && (\n          <div aria-hidden className=\"pointer-events-none absolute inset-0 z-2 bg-linear-to-t from-black/70 via-black/25 to-black/10\" />\n        )}\n\n        <div className=\"relative z-10 size-full\">{children}</div>\n\n        {n > 1 && (\n          <>\n            {[-1, 1].map((d) => (\n              <button\n                key={d}\n                type=\"button\"\n                aria-label={d < 0 ? \"Previous slide\" : \"Next slide\"}\n                onClick={() => go(d)}\n                className={cn(button, \"absolute top-1/2 z-20 -translate-y-1/2\", d < 0 ? \"left-3 sm:left-4\" : \"right-3 sm:right-4\")}\n              >\n                <Icon d={d < 0 ? \"m15 18-6-6 6-6\" : \"m9 18 6-6-6-6\"} />\n              </button>\n            ))}\n            <div className=\"pointer-events-none absolute inset-x-16 bottom-5 z-20 flex flex-wrap items-center justify-center gap-1\">\n              {images.map((_, i) => (\n                <button\n                  key={i}\n                  type=\"button\"\n                  aria-label={`Go to slide ${i + 1}`}\n                  aria-current={i === index || undefined}\n                  onClick={() => setIndex(i)}\n                  className=\"pointer-events-auto grid h-6 min-w-6 place-items-center rounded-full focus-visible:outline-2 focus-visible:outline-white\"\n                >\n                  <span\n                    className={cn(\n                      \"h-1.5 rounded-full bg-white/55 transition-all motion-reduce:transition-none\",\n                      i === index ? \"w-6 bg-white\" : \"w-1.5\"\n                    )}\n                  />\n                </button>\n              ))}\n            </div>\n          </>\n        )}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/image-slider.tsx"
    }
  ],
  "type": "registry:ui"
}