{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-carousel",
  "title": "Hero Carousel",
  "description": "Crossfading hero image slideshow with a progress pill, play/pause, and prev/next controls.",
  "registryDependencies": [
    "utils",
    "@soralabs/hooks-use-prefers-reduced-motion"
  ],
  "files": [
    {
      "path": "registry/primitives/effects/hero-carousel/index.tsx",
      "content": "// biome-ignore-all lint/performance/noImgElement: Crossfade layers animate opacity/filter/transform imperatively via WAAPI on plain img elements.\n// biome-ignore-all lint/correctness/useImageSize: Layers are absolutely positioned and sized via object-fit: contain, not intrinsic dimensions.\n\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type ComponentPropsWithoutRef,\n  type Ref,\n  useEffect,\n  useRef,\n} from \"react\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nconst DEFAULT_INTERVAL_MS = 4500;\nconst CROSSFADE_EASING = \"cubic-bezier(0.4, 0, 0.2, 1)\";\nconst OUT_DURATION_MS = 420;\nconst IN_DURATION_MS = 460;\nconst IN_DELAY_MS = 60;\n/** How long a manual next/prev takes to visually finish out the progress bar before it resets. */\nconst CATCHUP_DURATION_MS = 220;\n/** How long the full bar takes to collapse back to empty before the next cycle starts. */\nconst SHRINK_DURATION_MS = 220;\n\nexport interface HeroCarouselImage {\n  /** Accessible label for the image. */\n  alt: string;\n  /** Image URL. */\n  src: string;\n}\n\nexport interface HeroCarouselClassNames {\n  /** Prev/next button classes, layered on top of the defaults. */\n  chevron?: string;\n  /** Play/pause pill button classes, layered on top of the defaults. */\n  pill?: string;\n}\n\nexport interface HeroCarouselProps\n  extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Per-slot class overrides: `pill`, `chevron`. */\n  classNames?: HeroCarouselClassNames;\n  /** Images cycled through, in order. Fewer than 2 renders a single static image with no controls. */\n  images: HeroCarouselImage[];\n  /** Index to start on. Clamped into range; defaults to 0. */\n  initialIndex?: number;\n  /** Milliseconds each image is held before auto-advancing. */\n  intervalMs?: number;\n  ref?: Ref<HTMLDivElement>;\n}\n\nfunction HeroCarousel({\n  className,\n  classNames,\n  images,\n  initialIndex = 0,\n  intervalMs = DEFAULT_INTERVAL_MS,\n  ref,\n  ...props\n}: HeroCarouselProps) {\n  const stageRef = useRef<HTMLDivElement>(null);\n  const layerRefs = useRef<(HTMLImageElement | null)[]>([]);\n  const pillRef = useRef<HTMLButtonElement>(null);\n  const fillRef = useRef<HTMLSpanElement>(null);\n  const prevRef = useRef<HTMLButtonElement>(null);\n  const nextRef = useRef<HTMLButtonElement>(null);\n  const currentElRef = useRef<HTMLSpanElement>(null);\n\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const total = images.length;\n  const startIndex = Math.min(Math.max(initialIndex, 0), total - 1);\n  const first = images[startIndex];\n\n  useEffect(() => {\n    const stage = stageRef.current;\n    const pill = pillRef.current;\n    const fill = fillRef.current;\n    const prevBtn = prevRef.current;\n    const nextBtn = nextRef.current;\n    const currentEl = currentElRef.current;\n    const layers = layerRefs.current;\n\n    if (\n      !(stage && pill && fill && prevBtn && nextBtn && currentEl) ||\n      total < 2 ||\n      !(layers[0] && layers[1])\n    ) {\n      return;\n    }\n\n    const layer0 = layers[0];\n    const layer1 = layers[1];\n\n    // Preload every image so transitions never wait on the network.\n    for (const image of images) {\n      const pre = new Image();\n      pre.src = image.src;\n    }\n\n    let current = startIndex;\n    let active = 0; // index into [layer0, layer1] that is currently shown\n    let playing = true;\n    let crossfades: Animation[] = [];\n    let progressAnim: Animation | null = null;\n    // \"growing\": progressAnim is the real intervalMs countdown, so its\n    // currentTime maps to a progress ratio. \"transition\": progressAnim is a\n    // short catch-up/shrink flourish — currentTime is on that anim's own\n    // short timeline, not intervalMs, so it must never be read as a ratio.\n    let progressPhase: \"growing\" | \"idle\" | \"transition\" = \"idle\";\n    let destroyed = false;\n\n    const applyResting = () => {\n      const top = active === 0 ? layer0 : layer1;\n      const bottom = active === 0 ? layer1 : layer0;\n      top.style.opacity = \"1\";\n      top.style.filter = \"none\";\n      top.style.transform = \"none\";\n      top.style.zIndex = \"2\";\n      bottom.style.opacity = \"0\";\n      bottom.style.filter = \"none\";\n      bottom.style.transform = \"none\";\n      bottom.style.zIndex = \"1\";\n    };\n\n    const cancelCrossfades = () => {\n      for (const a of crossfades) {\n        try {\n          a.cancel();\n        } catch {\n          /* noop */\n        }\n      }\n      crossfades = [];\n    };\n\n    const updateCount = () => {\n      currentEl.textContent = String(current + 1);\n    };\n\n    /** Blur-and-scale crossfade between the outgoing and incoming layers. */\n    const startCrossfade = (out: HTMLImageElement, inc: HTMLImageElement) => {\n      inc.style.opacity = \"0\";\n\n      const outAnim = out.animate(\n        [\n          { filter: \"blur(0px)\", opacity: 1, transform: \"scale(1)\" },\n          { filter: \"blur(10px)\", opacity: 0, transform: \"scale(0.94)\" },\n        ],\n        {\n          duration: OUT_DURATION_MS,\n          easing: CROSSFADE_EASING,\n          fill: \"forwards\",\n        }\n      );\n\n      // The incoming image blurs in and scales up shortly after the\n      // outgoing one begins to leave.\n      const incAnim = inc.animate(\n        [\n          { filter: \"blur(10px)\", opacity: 0, transform: \"scale(0.94)\" },\n          { filter: \"blur(0px)\", opacity: 1, transform: \"scale(1)\" },\n        ],\n        {\n          delay: IN_DELAY_MS,\n          duration: IN_DURATION_MS,\n          easing: CROSSFADE_EASING,\n          fill: \"forwards\",\n        }\n      );\n\n      crossfades = [outAnim, incAnim];\n    };\n\n    const go = (target: number) => {\n      const next = ((target % total) + total) % total;\n      if (next === current) {\n        return;\n      }\n\n      // Interruptible: settle whatever is mid-flight before starting anew.\n      cancelCrossfades();\n      applyResting();\n\n      const out = active === 0 ? layer0 : layer1;\n      const inc = active === 0 ? layer1 : layer0;\n      const image = images[next];\n      if (!image) {\n        return;\n      }\n\n      inc.src = image.src;\n      inc.alt = image.alt;\n      out.style.zIndex = \"1\";\n      inc.style.zIndex = \"2\";\n\n      if (prefersReducedMotion) {\n        inc.style.opacity = \"1\";\n        out.style.opacity = \"0\";\n      } else {\n        startCrossfade(out, inc);\n      }\n\n      active = active === 0 ? 1 : 0;\n      current = next;\n      updateCount();\n    };\n\n    /**\n     * Collapses the full bar back to empty, anchored on the right — the\n     * mirror image of the left-anchored fill — instead of snapping straight\n     * to 0, then hands off to `onDone` to start the next cycle.\n     */\n    const shrinkProgress = (onDone: () => void) => {\n      if (prefersReducedMotion) {\n        progressPhase = \"idle\";\n        fill.style.transform = \"scaleX(0)\";\n        onDone();\n        return;\n      }\n\n      progressPhase = \"transition\";\n      fill.style.transformOrigin = \"right\";\n      const shrinkAnim = fill.animate(\n        [{ transform: \"scaleX(1)\" }, { transform: \"scaleX(0)\" }],\n        { duration: SHRINK_DURATION_MS, easing: \"ease-in\", fill: \"forwards\" }\n      );\n      progressAnim = shrinkAnim;\n      shrinkAnim.onfinish = () => {\n        fill.style.transformOrigin = \"\";\n        if (destroyed) {\n          return;\n        }\n        onDone();\n      };\n    };\n\n    const startProgress = () => {\n      if (progressAnim) {\n        // Clear onfinish first: cancel() never fires it, but this rules out\n        // any stray callback from a not-yet-settled prior animation.\n        progressAnim.onfinish = null;\n        progressAnim.cancel();\n      }\n      // Force the visual reset synchronously — shrinkProgress already leaves\n      // the bar at scaleX(0), this covers the paths that skip it.\n      fill.style.transform = \"scaleX(0)\";\n      fill.style.transformOrigin = \"\";\n\n      if (prefersReducedMotion) {\n        // No visible countdown; just schedule advances on a timer-like anim.\n        progressPhase = \"idle\";\n        progressAnim = fill.animate([{ opacity: 0 }, { opacity: 0 }], {\n          duration: intervalMs,\n        });\n      } else {\n        progressPhase = \"growing\";\n        progressAnim = fill.animate(\n          [{ transform: \"scaleX(0)\" }, { transform: \"scaleX(1)\" }],\n          { duration: intervalMs, easing: \"linear\", fill: \"forwards\" }\n        );\n      }\n      progressAnim.onfinish = () => {\n        if (destroyed || !playing) {\n          return;\n        }\n        shrinkProgress(() => {\n          if (destroyed || !playing) {\n            return;\n          }\n          go(current + 1);\n          startProgress();\n        });\n      };\n    };\n\n    const resetProgress = () => {\n      progressAnim?.cancel();\n      progressAnim = null;\n      progressPhase = \"idle\";\n      fill.style.transform = \"scaleX(0)\";\n      fill.style.transformOrigin = \"\";\n    };\n\n    /**\n     * Manual next/prev: smoothly finish the bar out to full instead of\n     * snapping, shrink it back to empty, then hand off to `onDone` (which\n     * restarts the countdown). Only reads a live progress ratio while a real\n     * countdown (\"growing\") is running — if a click lands mid catch-up/shrink\n     * flourish, that animation's own short currentTime is meaningless as a\n     * fraction of intervalMs, so treat it as already full instead.\n     */\n    const completeProgress = (onDone: () => void) => {\n      const anim = progressAnim;\n      let ratio = 1;\n      if (progressPhase === \"growing\" && anim) {\n        const elapsed =\n          typeof anim.currentTime === \"number\" ? anim.currentTime : intervalMs;\n        ratio = Math.min(1, Math.max(0, elapsed / intervalMs));\n      }\n      if (anim) {\n        anim.onfinish = null;\n        anim.cancel();\n      }\n      // Normalize origin in case we interrupted a mid-flight shrink.\n      fill.style.transformOrigin = \"\";\n\n      if (prefersReducedMotion) {\n        progressPhase = \"idle\";\n        fill.style.transform = \"scaleX(0)\";\n        onDone();\n        return;\n      }\n\n      if (ratio >= 1) {\n        fill.style.transform = \"scaleX(1)\";\n        shrinkProgress(onDone);\n        return;\n      }\n\n      progressPhase = \"transition\";\n      const catchUp = fill.animate(\n        [{ transform: `scaleX(${ratio})` }, { transform: \"scaleX(1)\" }],\n        { duration: CATCHUP_DURATION_MS, easing: \"ease-out\", fill: \"forwards\" }\n      );\n      progressAnim = catchUp;\n      catchUp.onfinish = () => {\n        if (destroyed) {\n          return;\n        }\n        shrinkProgress(onDone);\n      };\n    };\n\n    const setPlayingState = (value: boolean) => {\n      playing = value;\n      pill.classList.toggle(\"is-paused\", !value);\n      pill.setAttribute(\"aria-pressed\", String(!value));\n      pill.setAttribute(\n        \"aria-label\",\n        value ? \"Pause slideshow\" : \"Play slideshow\"\n      );\n    };\n\n    const play = () => {\n      if (playing) {\n        return;\n      }\n      setPlayingState(true);\n      if (progressAnim && progressAnim.playState === \"paused\") {\n        progressAnim.play();\n      } else {\n        startProgress();\n      }\n    };\n\n    const pause = () => {\n      if (!playing) {\n        return;\n      }\n      setPlayingState(false);\n      progressAnim?.pause();\n    };\n\n    const togglePlay = () => {\n      if (playing) {\n        pause();\n      } else {\n        play();\n      }\n    };\n\n    const step = (delta: number) => {\n      go(current + delta);\n      if (playing) {\n        completeProgress(() => {\n          if (!destroyed && playing) {\n            startProgress();\n          }\n        });\n      } else {\n        resetProgress();\n      }\n    };\n\n    const onStageClick = () => step(1);\n    const onPrev = (e: Event) => {\n      e.stopPropagation();\n      step(-1);\n    };\n    const onNext = (e: Event) => {\n      e.stopPropagation();\n      step(1);\n    };\n    const onPill = (e: Event) => {\n      e.stopPropagation();\n      togglePlay();\n    };\n\n    const onKey = (e: KeyboardEvent) => {\n      if (e.metaKey || e.ctrlKey || e.altKey) {\n        return;\n      }\n      const t = e.target as HTMLElement | null;\n      if (\n        t &&\n        (t.tagName === \"INPUT\" ||\n          t.tagName === \"TEXTAREA\" ||\n          t.tagName === \"SELECT\" ||\n          t.isContentEditable)\n      ) {\n        return;\n      }\n      if (e.key === \"ArrowRight\") {\n        e.preventDefault();\n        step(1);\n      } else if (e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        step(-1);\n      }\n    };\n\n    const onVisibility = () => {\n      if (!playing) {\n        return;\n      }\n      if (document.hidden) {\n        progressAnim?.pause();\n      } else {\n        progressAnim?.play();\n      }\n    };\n\n    stage.addEventListener(\"click\", onStageClick);\n    prevBtn.addEventListener(\"click\", onPrev);\n    nextBtn.addEventListener(\"click\", onNext);\n    pill.addEventListener(\"click\", onPill);\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    applyResting();\n    updateCount();\n    setPlayingState(true);\n    startProgress();\n\n    return () => {\n      destroyed = true;\n      cancelCrossfades();\n      progressAnim?.cancel();\n      stage.removeEventListener(\"click\", onStageClick);\n      prevBtn.removeEventListener(\"click\", onPrev);\n      nextBtn.removeEventListener(\"click\", onNext);\n      pill.removeEventListener(\"click\", onPill);\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [images, intervalMs, prefersReducedMotion, startIndex, total]);\n\n  if (!first) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative block h-[320px] w-full overflow-hidden rounded-[16px] bg-[#292929] max-[480px]:h-[280px]\",\n        className\n      )}\n      ref={ref}\n      {...props}\n    >\n      <div className=\"absolute inset-0 cursor-pointer\" ref={stageRef}>\n        <img\n          alt={first.alt}\n          className=\"absolute inset-0 size-full select-none object-contain [backface-visibility:hidden] [will-change:opacity,transform,filter]\"\n          decoding=\"async\"\n          draggable={false}\n          loading=\"eager\"\n          ref={(el) => {\n            layerRefs.current[0] = el;\n          }}\n          src={first.src}\n        />\n        <img\n          alt=\"\"\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 size-full select-none object-contain [backface-visibility:hidden] [will-change:opacity,transform,filter]\"\n          decoding=\"async\"\n          draggable={false}\n          loading=\"lazy\"\n          ref={(el) => {\n            layerRefs.current[1] = el;\n          }}\n          src={first.src}\n        />\n      </div>\n\n      {total > 1 && (\n        <div className=\"pointer-events-none absolute inset-x-4 top-4 z-3 flex items-center justify-between\">\n          <button\n            aria-label=\"Pause slideshow\"\n            aria-pressed=\"false\"\n            className={cn(\n              \"group pointer-events-auto relative inline-flex h-[26px] items-center overflow-hidden rounded-full border-0 bg-[#3e3e3e] px-2.5 font-[inherit] text-white text-xs leading-none transition-colors duration-200 [-webkit-tap-highlight-color:transparent] [transition-timing-function:ease]\",\n              \"hover:bg-[#4a4a4a] focus-visible:outline focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2\",\n              classNames?.pill\n            )}\n            ref={pillRef}\n            type=\"button\"\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 z-0 origin-left bg-white/[0.18]\"\n              ref={fillRef}\n              style={{ transform: \"scaleX(0)\" }}\n            />\n            <span className=\"relative z-1 inline-flex items-center gap-1.5\">\n              <span\n                aria-hidden=\"true\"\n                className=\"relative inline-block size-[11px] shrink-0\"\n              >\n                <span className=\"absolute inset-0 bg-white transition-[clip-path] duration-150 [clip-path:polygon(18.75%_12.5%,37.5%_12.5%,37.5%_87.5%,18.75%_87.5%)] [transition-timing-function:cubic-bezier(0.65,0,0.35,1)] motion-reduce:transition-none group-[.is-paused]:[clip-path:polygon(25%_12.5%,56.25%_31.25%,56.25%_68.75%,25%_87.5%)]\" />\n                <span className=\"absolute inset-0 bg-white transition-[clip-path] duration-150 [clip-path:polygon(62.5%_12.5%,81.25%_12.5%,81.25%_87.5%,62.5%_87.5%)] [transition-timing-function:cubic-bezier(0.65,0,0.35,1)] motion-reduce:transition-none group-[.is-paused]:[clip-path:polygon(56.25%_31.25%,87.5%_50%,87.5%_50%,56.25%_68.75%)]\" />\n              </span>\n              <span className=\"hidden pt-px tabular-nums tracking-[0.02em]\">\n                <span ref={currentElRef}>1</span>/<span>{total}</span>\n              </span>\n            </span>\n          </button>\n\n          <span className=\"pointer-events-none inline-flex items-center gap-1.5\">\n            <button\n              aria-label=\"Previous image\"\n              className={cn(\n                \"pointer-events-auto inline-flex size-[26px] items-center justify-center rounded-full border-0 bg-[#3e3e3e] text-white transition-colors duration-200 [-webkit-tap-highlight-color:transparent] [transition-timing-function:ease] hover:bg-[#4a4a4a] focus-visible:outline focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2\",\n                classNames?.chevron\n              )}\n              ref={prevRef}\n              type=\"button\"\n            >\n              <svg\n                aria-hidden=\"true\"\n                fill=\"none\"\n                height=\"14\"\n                viewBox=\"0 0 14 14\"\n                width=\"14\"\n              >\n                <path\n                  d=\"M8.5 3L4.5 7l4 4\"\n                  stroke=\"currentColor\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"1.5\"\n                />\n              </svg>\n            </button>\n\n            <button\n              aria-label=\"Next image\"\n              className={cn(\n                \"pointer-events-auto inline-flex size-[26px] items-center justify-center rounded-full border-0 bg-[#3e3e3e] text-white transition-colors duration-200 [-webkit-tap-highlight-color:transparent] [transition-timing-function:ease] hover:bg-[#4a4a4a] focus-visible:outline focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2\",\n                classNames?.chevron\n              )}\n              ref={nextRef}\n              type=\"button\"\n            >\n              <svg\n                aria-hidden=\"true\"\n                fill=\"none\"\n                height=\"14\"\n                viewBox=\"0 0 14 14\"\n                width=\"14\"\n              >\n                <path\n                  d=\"M5.5 3l4 4-4 4\"\n                  stroke=\"currentColor\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"1.5\"\n                />\n              </svg>\n            </button>\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport { HeroCarousel };\n",
      "type": "registry:ui",
      "target": "components/sora-ui/effects/hero-carousel.tsx"
    }
  ],
  "meta": {
    "keywords": [
      "carousel",
      "hero",
      "slideshow",
      "crossfade",
      "images",
      "gallery"
    ]
  },
  "type": "registry:ui"
}
