{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"carousel","type":"registry:ui","title":"Carousel","description":"Slides in a row to swipe or step through, with arrows, dots, looping and autoplay.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json"],"files":[{"path":"registry/dashral/carousel.tsx","type":"registry:file","target":"components/dashral/carousel.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeftIcon, ChevronRightIcon, PauseIcon, PlayIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { reducedMotion } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\n\ntype Breakpoint = \"base\" | \"sm\" | \"md\" | \"lg\" | \"xl\"\ntype PerView = number | Partial<Record<Breakpoint, number>>\n\ntype CarouselProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  /** The slides, in order. */\n  items: React.ReactNode[]\n  /** Slides in view at once, or per screen size from the smallest up: { base: 1, sm: 2, lg: 3 }. */\n  perView?: PerView\n  /** Space between slides, in pixels. */\n  gap?: number\n  /** The first slide in view (from 0), controlled. */\n  index?: number\n  /** The first slide in view at the start, uncontrolled. */\n  defaultIndex?: number\n  /** Called when another slide comes first, by any means: arrows, dots, swiping, the keyboard or autoplay. */\n  onIndexChange?: (index: number) => void\n  /** From the end, Next goes back to the start (and Previous the other way). */\n  loop?: boolean\n  /**\n   * Moves on by itself every this many milliseconds, back to the start at the end. It pauses while the pointer is\n   * over it or focus is inside, and has a Pause button. With reduced motion it starts paused.\n   */\n  autoplay?: number\n  /** The arrows: over the slides' edges (on wider screens), in a row under them, or none — swiping still works. */\n  controls?: \"overlay\" | \"below\" | \"none\"\n  /** Under the slides: a dot per position, \"2 / 5\", or nothing. */\n  indicator?: \"dots\" | \"count\" | \"none\"\n  /** Names it for screen readers, e.g. \"Featured products\". */\n  label?: string\n  classNames?: { viewport?: string; slide?: string; controls?: string; arrow?: string }\n}\n\nconst breakpoints: Breakpoint[] = [\"base\", \"sm\", \"md\", \"lg\", \"xl\"]\n\n/** --pv-<breakpoint> for each size, each falling back to the one below, so the classes below can pick theirs. */\nfunction perViewVars(perView: PerView): React.CSSProperties {\n  const counts = typeof perView === \"number\" ? { base: perView } : perView\n  let last = counts.base ?? 1\n  return Object.fromEntries(\n    breakpoints.map((bp) => {\n      last = counts[bp] ?? last\n      return [`--pv-${bp}`, last]\n    })\n  ) as React.CSSProperties\n}\n\n\n/**\n * Slides in a row that you swipe, drag or step through, in one tag: arrows, dots (or \"2 / 5\"), the keyboard, looping\n * and autoplay. Built on native scroll snapping, so touch and trackpads feel like the platform's, and a mouse can drag\n * it too; several slides can show at once, a different number per screen size. Right to left, the order and the arrows\n * mirror.\n */\nfunction Carousel({\n  items,\n  perView = 1,\n  gap = 16,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  loop = false,\n  autoplay,\n  controls = \"overlay\",\n  indicator = \"dots\",\n  label,\n  className,\n  classNames,\n  style,\n  ...props\n}: CarouselProps) {\n  const { t, locale } = useLocale()\n  const number = new Intl.NumberFormat(locale)\n  const viewport = React.useRef<HTMLDivElement>(null)\n  const [current, setCurrent] = React.useState(indexProp ?? defaultIndex)\n  // The last position the first slide can take (5 slides, 2 in view: 3), measured.\n  const [last, setLast] = React.useState(Math.max(0, items.length - 1))\n  const [playing, setPlaying] = React.useState(false)\n  const [held, setHeld] = React.useState({ hover: false, focus: false })\n  const reported = React.useRef(current)\n  const total = items.length\n\n  const slides = () => [...(viewport.current?.children ?? [])] as HTMLElement[]\n\n  const scrollTo = React.useCallback((to: number, instant = false) => {\n    const node = viewport.current\n    const target = node?.children[to] as HTMLElement | undefined\n    if (!node || !target) return\n    const rtl = getComputedStyle(node).direction === \"rtl\"\n    const box = node.getBoundingClientRect()\n    const slide = target.getBoundingClientRect()\n    node.scrollBy({ left: rtl ? slide.right - box.right : slide.left - box.left, behavior: instant || reducedMotion() ? \"auto\" : \"smooth\" })\n  }, [])\n\n  // Where the row is: the slide nearest the start, and how far it can go. Again whenever it scrolls or resizes.\n  React.useEffect(() => {\n    const node = viewport.current\n    if (!node) return\n    let frame = 0\n    const measure = () => {\n      const [first, second] = slides()\n      if (!first) return\n      const stride = second ? Math.abs(second.offsetLeft - first.offsetLeft) : first.offsetWidth\n      if (!stride) return\n      const visible = Math.max(1, Math.round((node.clientWidth + gap) / stride))\n      const end = Math.max(0, total - visible)\n      // At the very end the last slides can't reach the start, so count that as the last position.\n      const atEnd = Math.abs(node.scrollLeft) + node.clientWidth >= node.scrollWidth - 2\n      const at = atEnd ? end : Math.min(end, Math.round(Math.abs(node.scrollLeft) / stride))\n      setLast(end)\n      setCurrent(at)\n      if (at !== reported.current) {\n        reported.current = at\n        onIndexChange?.(at)\n      }\n    }\n    const onScroll = () => {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(measure)\n    }\n    measure()\n    node.addEventListener(\"scroll\", onScroll, { passive: true })\n    const resize = new ResizeObserver(onScroll)\n    resize.observe(node)\n    return () => {\n      cancelAnimationFrame(frame)\n      node.removeEventListener(\"scroll\", onScroll)\n      resize.disconnect()\n    }\n    // onIndexChange is read when it fires; a new function each render shouldn't rebuild the observers.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gap, total])\n\n  // Start where asked, without the slide-in.\n  React.useLayoutEffect(() => {\n    if (defaultIndex || indexProp) scrollTo(indexProp ?? defaultIndex, true)\n    // Only on mount.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  // Controlled: follow the index you pass.\n  React.useEffect(() => {\n    if (indexProp !== undefined && indexProp !== reported.current) scrollTo(indexProp)\n  }, [indexProp, scrollTo])\n\n  const go = React.useCallback(\n    (to: number, wrap = loop) => {\n      const next = to > last ? (wrap ? 0 : last) : to < 0 ? (wrap ? last : 0) : to\n      scrollTo(next)\n    },\n    [last, loop, scrollTo]\n  )\n\n  // Autoplay: on unless motion is reduced; it waits while you're pointing at it or working in it, or the tab is hidden.\n  React.useEffect(() => {\n    if (autoplay) setPlaying(!reducedMotion())\n  }, [autoplay])\n  React.useEffect(() => {\n    if (!autoplay || !playing || held.hover || held.focus) return\n    const timer = window.setTimeout(() => {\n      if (!document.hidden) go(current + 1, true)\n    }, autoplay)\n    return () => window.clearTimeout(timer)\n  }, [autoplay, playing, held, current, go])\n\n  // Dragging with a mouse (touch and trackpads scroll natively). While you drag, snapping is off so the row follows\n  // the pointer; on release it settles on the next or previous slide if you dragged far enough, else back.\n  const drag = React.useRef<{ x: number; scroll: number; moved: boolean; id: number } | null>(null)\n  const [dragging, setDragging] = React.useState(false)\n\n  function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n    if (event.pointerType !== \"mouse\" || event.button !== 0) return\n    drag.current = { x: event.clientX, scroll: event.currentTarget.scrollLeft, moved: false, id: event.pointerId }\n  }\n\n  function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    const state = drag.current\n    if (!state || state.id !== event.pointerId) return\n    const dx = event.clientX - state.x\n    if (!state.moved) {\n      if (Math.abs(dx) < 5) return\n      // Past a few pixels it's a drag, not a click: take the pointer and let the row follow it.\n      state.moved = true\n      event.currentTarget.setPointerCapture(event.pointerId)\n      setDragging(true)\n    }\n    event.currentTarget.scrollLeft = state.scroll - dx\n  }\n\n  function onPointerUp(event: React.PointerEvent<HTMLDivElement>) {\n    const state = drag.current\n    if (!state || state.id !== event.pointerId) return\n    drag.current = null\n    if (!state.moved) return\n    const node = event.currentTarget\n    if (node.hasPointerCapture(event.pointerId)) node.releasePointerCapture(event.pointerId)\n    const dx = event.clientX - state.x\n    const rtl = getComputedStyle(node).direction === \"rtl\"\n    const [first, second] = [...node.children] as HTMLElement[]\n    const stride = first && second ? Math.abs(second.offsetLeft - first.offsetLeft) : node.clientWidth\n    // Where the row stopped, in slides (1.4 = past the second slide). Dragged far enough, it moves on to the next\n    // slide in the direction you dragged; a short drag goes back to the nearest one.\n    const position = Math.abs(node.scrollLeft) / stride\n    const forward = rtl ? dx > 0 : dx < 0\n    const far = Math.abs(dx) > Math.min(80, stride / 4)\n    const to = far ? (forward ? Math.ceil(position - 0.05) : Math.floor(position + 0.05)) : Math.round(position)\n    go(Math.max(0, Math.min(last, to)), false)\n    // Snapping comes back once the row has settled, so it doesn't jump mid-glide.\n    const done = () => setDragging(false)\n    node.addEventListener(\"scrollend\", done, { once: true })\n    window.setTimeout(done, 700)\n  }\n\n  function onKeyDown(event: React.KeyboardEvent) {\n    const rtl = getComputedStyle(event.currentTarget).direction === \"rtl\"\n    const step = { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 }[event.key]\n    if (step) {\n      event.preventDefault()\n      go(current + step)\n    } else if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault()\n      scrollTo(event.key === \"Home\" ? 0 : last)\n    }\n  }\n\n  const canPrev = loop || current > 0\n  const canNext = loop || current < last\n  const arrowClass = cn(\n    \"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background/85 text-foreground shadow-xs backdrop-blur-sm transition-[opacity,background-color] outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none [&_svg]:size-4 [&_svg]:rtl:-scale-x-100\",\n    // Over the slides a disabled arrow steps out of the way; in the row under them it stays, dimmed, so the pair keeps its place.\n    controls === \"overlay\" ? \"disabled:opacity-0\" : \"disabled:opacity-40\",\n    classNames?.arrow\n  )\n  const prev = (\n    <button type=\"button\" className={arrowClass} aria-label={t(\"previousSlide\")} disabled={!canPrev} onClick={() => go(current - 1)}>\n      <ChevronLeftIcon aria-hidden />\n    </button>\n  )\n  const next = (\n    <button type=\"button\" className={arrowClass} aria-label={t(\"nextSlide\")} disabled={!canNext} onClick={() => go(current + 1)}>\n      <ChevronRightIcon aria-hidden />\n    </button>\n  )\n  const showRow = indicator !== \"none\" || controls === \"below\" || !!autoplay\n\n  return (\n    <section\n      aria-roledescription=\"carousel\"\n      aria-label={label ?? t(\"carousel\")}\n      data-slot=\"carousel\"\n      className={cn(\"relative w-full min-w-0\", className)}\n      style={{ ...perViewVars(perView), \"--gap\": `${gap}px`, ...style } as React.CSSProperties}\n      onPointerEnter={() => setHeld((h) => ({ ...h, hover: true }))}\n      onPointerLeave={() => setHeld((h) => ({ ...h, hover: false }))}\n      onFocus={() => setHeld((h) => ({ ...h, focus: true }))}\n      onBlur={(event) => !event.currentTarget.contains(event.relatedTarget) && setHeld((h) => ({ ...h, focus: false }))}\n      {...props}\n    >\n      <div className=\"relative\">\n        <div\n          ref={viewport}\n          data-slot=\"carousel-viewport\"\n          tabIndex={0}\n          // Announce the slide that comes in, except while it moves by itself.\n          aria-live={playing && !held.hover && !held.focus ? \"off\" : \"polite\"}\n          onKeyDown={onKeyDown}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerUp}\n          // After a drag, the click that ends it mustn't open a link or press a button in the slide.\n          onClickCapture={(event) => {\n            if (dragging) {\n              event.preventDefault()\n              event.stopPropagation()\n            }\n          }}\n          onDragStart={(event) => event.preventDefault()}\n          data-dragging={dragging || undefined}\n          className={cn(\n            \"flex snap-x snap-mandatory gap-(--gap) overflow-x-auto overscroll-x-contain rounded-xl outline-none [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-ring/50 [&::-webkit-scrollbar]:hidden\",\n            \"cursor-grab data-dragging:cursor-grabbing data-dragging:snap-none data-dragging:select-none\",\n            \"[--per-view:var(--pv-base)] sm:[--per-view:var(--pv-sm)] md:[--per-view:var(--pv-md)] lg:[--per-view:var(--pv-lg)] xl:[--per-view:var(--pv-xl)]\",\n            classNames?.viewport\n          )}\n        >\n          {items.map((item, i) => (\n            <div\n              key={i}\n              role=\"group\"\n              aria-roledescription=\"slide\"\n              aria-label={t(\"slideOf\", { index: number.format(i + 1), total: number.format(total) })}\n              data-slot=\"carousel-slide\"\n              data-active={i === current || undefined}\n              className={cn(\"min-w-0 shrink-0 grow-0 basis-[calc((100%_-_var(--gap)_*_(var(--per-view)_-_1))_/_var(--per-view))] snap-start\", classNames?.slide)}\n            >\n              {item}\n            </div>\n          ))}\n        </div>\n        {controls === \"overlay\" && (\n          <>\n            <div className=\"absolute inset-y-0 start-3 hidden items-center sm:flex\">{prev}</div>\n            <div className=\"absolute inset-y-0 end-3 hidden items-center sm:flex\">{next}</div>\n          </>\n        )}\n      </div>\n\n      {showRow && (\n        <div data-slot=\"carousel-controls\" className={cn(\"mt-4 flex items-center gap-3\", controls === \"below\" ? \"justify-between\" : \"justify-center\", classNames?.controls)}>\n          <div className=\"flex items-center gap-3\">\n            {autoplay ? (\n              <button\n                type=\"button\"\n                className={cn(arrowClass, \"size-8\")}\n                aria-label={playing ? t(\"pauseSlides\") : t(\"playSlides\")}\n                onClick={() => setPlaying(!playing)}\n              >\n                {playing ? <PauseIcon aria-hidden /> : <PlayIcon aria-hidden />}\n              </button>\n            ) : null}\n            {indicator === \"dots\" && (\n              <div className=\"flex items-center gap-1.5\">\n                {Array.from({ length: last + 1 }, (_, i) => (\n                  <button\n                    key={i}\n                    type=\"button\"\n                    aria-label={t(\"goToSlide\", { index: number.format(i + 1) })}\n                    aria-current={i === current || undefined}\n                    onClick={() => scrollTo(i)}\n                    className=\"group grid h-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50\"\n                  >\n                    <span\n                      className={cn(\n                        \"block h-1.5 rounded-full transition-[width,background-color] duration-(--duration-standard) ease-spring motion-reduce:transition-none\",\n                        i === current ? \"w-5 bg-foreground\" : \"w-1.5 bg-foreground/20 group-hover:bg-foreground/40\"\n                      )}\n                    />\n                  </button>\n                ))}\n              </div>\n            )}\n            {indicator === \"count\" && (\n              <p aria-hidden className=\"text-sm text-muted-foreground tabular-nums\">\n                <span className=\"font-medium text-foreground\">{number.format(current + 1)}</span> / {number.format(last + 1)}\n              </p>\n            )}\n          </div>\n          {controls === \"below\" && (\n            <div className=\"flex items-center gap-2\">\n              {prev}\n              {next}\n            </div>\n          )}\n        </div>\n      )}\n    </section>\n  )\n}\n\nexport { Carousel, type CarouselProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}