{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel",
  "type": "registry:ui",
  "title": "Carousel",
  "description": "Browse a row of slides by dragging, flicking, or arrowing through them.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/carousel/carousel.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  isValidElement,\n  useEffect,\n  useEffectEvent,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  CSSProperties,\n  KeyboardEvent as ReactKeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n  Ref,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  motionValue,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue, TargetAndTransition } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, Pause, Play } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/**\n * A row of slides people browse by dragging, flicking, a trackpad swipe, the arrow keys, or the controls below it. Use it for a short,\n * ordered set of peers such as products or case studies, where the neighbours stay in view as a hint that there is more.\n * The active slide sits in the center, its neighbours shrink and dim with distance, and the page indicator stretches into a pill that\n * travels with the slides frame by frame.\n */\nexport interface CarouselProps {\n  /** Names the carousel for assistive technology, for example \"From the Harbour suites\". */\n  label: string;\n  /** One child per slide. */\n  children: ReactNode;\n  /** Controlled active slide. Pair with `onIndexChange`. */\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Width of one slide as a CSS length. Use `cqw` for a share of the carousel width. */\n  slideSize?: string;\n  /** Names each slide and its indicator. Defaults to \"2 of 5\". */\n  slideLabel?: (index: number, count: number) => string;\n  /** Milliseconds per slide. Adds a play control that rotates through the slides; rotation pauses on hover, keyboard focus, and drag, and is unavailable with reduced motion. */\n  interval?: number;\n  /** Starts rotating on mount. Off by default, so motion begins with the viewer. */\n  autoplay?: boolean;\n  className?: string;\n}\n\n/** Play and the arrows. Only the press scales, on a faster curve than the release. */\nconst control =\n  \"grid size-control-sm flex-none cursor-pointer place-items-center rounded-button border border-line bg-surface p-0 text-ink [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] hover:not-aria-disabled:bg-hover active:not-aria-disabled:[transform:scale(.97)] active:not-aria-disabled:[transition:transform_100ms_var(--ease-standard)] aria-disabled:cursor-default aria-disabled:text-ink-3 aria-disabled:opacity-50 motion-reduce:transition-none motion-reduce:[transform:none] motion-reduce:active:not-aria-disabled:transition-none motion-reduce:active:not-aria-disabled:[transform:none]\";\n\ntype Drag = {\n  pointer: number;\n  startX: number;\n  startY: number;\n  origin: number;\n  from: number;\n  locked: \"x\" | \"y\" | null;\n  caught: boolean;\n  samples: [number, number][];\n};\n\n/** Movement before a press is read as a drag or handed to the page as a vertical scroll. */\nconst SLOP = 6;\n/** Release speed in px/s that always moves at least one slide. */\nconst FLICK = 360;\n/** Width of a resting dot and of the active pill, in px. */\nconst DOT = 6,\n  PILL = 22;\n/** Scale and opacity of a slide one full step from the center. */\nconst SCALE = 0.9,\n  DIM = 0.5;\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n/** Momentum projection with a paging deceleration rate: where a flick would come to rest. */\nconst project = (velocity: number, rate = 0.995) => ((velocity / 1000) * rate) / (1 - rate);\n/** Past an edge every pixel costs more, like pulling against elastic. */\nconst rubber = (overshoot: number, dimension: number) =>\n  (overshoot * dimension * 0.55) / (dimension + 0.55 * overshoot);\nconst unrubber = (distance: number, dimension: number) =>\n  (distance * dimension) / (0.55 * Math.max(1, dimension - distance));\nconst defaultSlideLabel = (index: number, count: number) => `${index + 1} of ${count}`;\nconst subscribeNothing = () => () => {};\n\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconRest: TargetAndTransition = { opacity: 1, scale: 1, filter: \"blur(0px)\" };\nconst iconOut: TargetAndTransition = {\n  ...iconIn,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\nconst iconEnter = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n  filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n} as const;\n\nfunction velocityOf(samples: [number, number][], now: number) {\n  const recent = samples.filter(([time]) => now - time <= 90);\n  if (recent.length < 2 || now - recent[recent.length - 1]![0] > 50) return 0;\n  const [firstTime, firstX] = recent[0]!,\n    [lastTime, lastX] = recent[recent.length - 1]!;\n  return lastTime > firstTime ? (lastX - firstX) / ((lastTime - firstTime) / 1000) : 0;\n}\n\nexport function Carousel({\n  label,\n  children,\n  index: controlledIndex,\n  defaultIndex = 0,\n  onIndexChange,\n  slideSize = \"min(80cqw, 340px)\",\n  slideLabel = defaultSlideLabel,\n  interval,\n  autoplay = false,\n  className,\n}: CarouselProps) {\n  const slides = Children.toArray(children);\n  const count = slides.length;\n  const last = Math.max(0, count - 1);\n  const id = useId();\n  const reduced = useReducedMotion() ?? false;\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  const [ownIndex, setOwnIndex] = useState(() => clamp(defaultIndex, 0, last));\n  const index = clamp(controlledIndex ?? ownIndex, 0, last);\n  const [startIndex] = useState(index);\n  const [playing, setPlaying] = useState(autoplay);\n  const [hovered, setHovered] = useState(false);\n  const [focusPaused, setFocusPaused] = useState(false);\n  const [dragging, setDragging] = useState(false);\n  const [inView, setInView] = useState(true);\n  const [pageVisible, setPageVisible] = useState(true);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const x = useMotionValue(0);\n  const progress = useMotionValue(index);\n  // One drain per indicator, so a pill that just ran out keeps its state while it shrinks away.\n  const drains = useMemo(() => Array.from({ length: count }, () => motionValue(0)), [count]);\n  const rootRef = useRef<HTMLElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const playRef = useRef<HTMLButtonElement>(null);\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const metrics = useRef({ step: 0, width: 0 });\n  const target = useRef(index);\n  const travel = useRef<AnimationPlaybackControls | null>(null);\n  const drag = useRef<Drag | null>(null);\n  const wheel = useRef({ active: false, raw: 0, timer: 0 });\n  const swallowClick = useRef(false);\n  const canRotate = Boolean(interval) && count > 1 && !(hydrated && reduced);\n  const running = canRotate && playing && !hovered && !focusPaused && !dragging && inView && pageVisible;\n\n  const bounds = () => ({ min: -last * metrics.current.step, max: 0 });\n  const band = (raw: number) => {\n    const { min, max } = bounds(),\n      size = metrics.current.width || 1;\n    return raw > max ? max + rubber(raw - max, size) : raw < min ? min - rubber(min - raw, size) : raw;\n  };\n  const unband = (shown: number) => {\n    const { min, max } = bounds(),\n      size = metrics.current.width || 1;\n    return shown > max ? max + unrubber(shown - max, size) : shown < min ? min - unrubber(min - shown, size) : shown;\n  };\n\n  /** Springs the track to a slide from wherever it is, carrying any velocity it already has. */\n  function glide(to: number, velocity = 0) {\n    const { step } = metrics.current;\n    travel.current?.stop();\n    travel.current = null;\n    if (!step) return;\n    if (reduced) {\n      x.jump(-to * step);\n      return;\n    }\n    travel.current = animate(x, -to * step, { ...motionTokens.spring.smooth, velocity });\n  }\n\n  function select(to: number, { velocity = 0, announce = true }: { velocity?: number; announce?: boolean } = {}) {\n    const next = clamp(to, 0, last);\n    target.current = next;\n    glide(next, velocity);\n    if (next === index) return;\n    if (controlledIndex === undefined) setOwnIndex(next);\n    onIndexChange?.(next);\n    if (announce) setAnnouncement(slideLabel(next, count));\n  }\n\n  const measure = useEffectEvent(() => {\n    const track = trackRef.current,\n      viewport = viewportRef.current;\n    const [first, second] = Array.from(track?.children ?? []) as HTMLElement[];\n    if (!track || !viewport || !first) return;\n    // Centers are unaffected by each slide's scale, and rects keep the sub-pixel step that offsetLeft would round away.\n    const center = (node: HTMLElement) => {\n      const rect = node.getBoundingClientRect();\n      return rect.left + rect.width / 2;\n    };\n    const step = second ? center(second) - center(first) : first.offsetWidth;\n    const changed = step !== metrics.current.step;\n    metrics.current = { step, width: viewport.clientWidth };\n    // The server positions the start slide with CSS; from here on the motion value owns the track.\n    track.dataset.measured = \"\";\n    if (changed && !drag.current) {\n      travel.current?.stop();\n      travel.current = null;\n      x.jump(-target.current * step);\n      progress.set(target.current);\n    }\n  });\n\n  useLayoutEffect(() => {\n    measure();\n    const viewport = viewportRef.current;\n    if (!viewport || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => measure());\n    observer.observe(viewport);\n    return () => observer.disconnect();\n  }, []);\n\n  // Progress is the continuous slide position that the scale, dim, and indicator read every frame.\n  useEffect(\n    () =>\n      x.on(\"change\", (value) => {\n        const { step } = metrics.current;\n        if (!step) return;\n        const position = -value / step;\n        progress.set(position);\n        drains.forEach((drain, i) => {\n          if (i !== target.current && Math.abs(position - i) >= 1 && drain.get() !== 0) drain.jump(0);\n        });\n      }),\n    [x, progress, drains],\n  );\n\n  const followIndexProp = useEffectEvent((next: number) => {\n    if (target.current !== next) {\n      target.current = next;\n      glide(next);\n    }\n  });\n  useEffect(() => {\n    followIndexProp(index);\n  }, [index]);\n\n  const advance = useEffectEvent(() => select(index >= last ? 0 : index + 1, { announce: false }));\n  useEffect(() => {\n    if (!running || !interval) return;\n    const drain = drains[index];\n    if (!drain) return;\n    const controls = animate(drain, 1, {\n      duration: ((1 - drain.get()) * interval) / 1000,\n      ease: \"linear\",\n      onComplete: () => advance(),\n    });\n    return () => controls.stop();\n  }, [running, index, interval, drains]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!interval || !root) return;\n    const observer =\n      typeof IntersectionObserver === \"undefined\"\n        ? null\n        : new IntersectionObserver(([entry]) => {\n            if (entry) setInView(entry.isIntersecting);\n          });\n    observer?.observe(root);\n    const onVisibility = () => setPageVisible(document.visibilityState !== \"hidden\");\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      observer?.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [interval]);\n\n  // A horizontal trackpad swipe moves the track 1:1 and settles on the nearest slide once the swipe, including its momentum, ends.\n  const onWheel = useEffectEvent((event: WheelEvent) => {\n    const state = wheel.current;\n    if (count < 2 || drag.current || !metrics.current.step) return;\n    if (!state.active && Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;\n    event.preventDefault();\n    if (!state.active) {\n      state.active = true;\n      travel.current?.stop();\n      travel.current = null;\n      state.raw = unband(x.get());\n    }\n    state.raw -= event.deltaX * (event.deltaMode === 1 ? 16 : 1);\n    x.set(band(state.raw));\n    window.clearTimeout(state.timer);\n    state.timer = window.setTimeout(() => {\n      state.active = false;\n      select(Math.round(-x.get() / metrics.current.step), { velocity: x.getVelocity() });\n    }, 120);\n  });\n  useEffect(() => {\n    const viewport = viewportRef.current,\n      state = wheel.current;\n    if (!viewport) return;\n    const listener = (event: WheelEvent) => onWheel(event);\n    viewport.addEventListener(\"wheel\", listener, { passive: false });\n    return () => {\n      viewport.removeEventListener(\"wheel\", listener);\n      window.clearTimeout(state.timer);\n    };\n  }, []);\n\n  function slideAt(clientX: number) {\n    const nodes = Array.from(trackRef.current?.children ?? []);\n    const hit = nodes.findIndex((node) => {\n      const rect = node.getBoundingClientRect();\n      return clientX >= rect.left && clientX <= rect.right;\n    });\n    return hit < 0 ? null : hit;\n  }\n\n  function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    if (count < 2 || drag.current || !metrics.current.step || (event.pointerType === \"mouse\" && event.button !== 0))\n      return;\n    swallowClick.current = false;\n    // Touching a visibly moving track catches it where it is, the way a hand stops a spinning wheel. A settling tail is left alone so a tap still selects.\n    const caught =\n      travel.current !== null && x.isAnimating() && Math.abs(x.get() + target.current * metrics.current.step) > 12;\n    if (caught) {\n      travel.current?.stop();\n      travel.current = null;\n    }\n    drag.current = {\n      pointer: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      origin: 0,\n      from: target.current,\n      locked: null,\n      caught,\n      samples: [],\n    };\n  }\n\n  function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n    const current = drag.current;\n    if (!current || current.pointer !== event.pointerId) return;\n    if (!current.locked) {\n      const dx = event.clientX - current.startX,\n        dy = event.clientY - current.startY;\n      if (Math.abs(dx) < SLOP && Math.abs(dy) < SLOP) return;\n      if (Math.abs(dy) > Math.abs(dx)) {\n        current.locked = \"y\";\n        return;\n      }\n      // The slop is consumed rather than applied, so the track starts moving from the finger without a jump.\n      current.locked = \"x\";\n      travel.current?.stop();\n      travel.current = null;\n      current.startX = event.clientX;\n      current.origin = unband(x.get());\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setDragging(true);\n    }\n    if (current.locked !== \"x\") return;\n    x.set(band(current.origin + event.clientX - current.startX));\n    current.samples.push([event.timeStamp, event.clientX]);\n    if (current.samples.length > 16) current.samples.shift();\n  }\n\n  function finish(event: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) {\n    const current = drag.current;\n    if (!current || current.pointer !== event.pointerId) return;\n    drag.current = null;\n    const { step } = metrics.current;\n    if (current.locked === \"x\") {\n      swallowClick.current = true;\n      setDragging(false);\n      const velocity = cancelled ? 0 : velocityOf(current.samples, event.timeStamp);\n      // The landing slide comes from where the flick would carry the track, not from where the finger let go.\n      let to = Math.round(-(x.get() + project(velocity)) / step);\n      if (Math.abs(velocity) > FLICK) {\n        const direction = velocity < 0 ? 1 : -1;\n        if ((to - current.from) * direction < 1) to = current.from + direction;\n      }\n      // Inside the bounds the track moves 1:1 with the pointer, so the sampled release speed carries into the spring even when the\n      // last move landed a frame early (the value's own velocity reads zero after 30ms). Past an edge, the banded value speed is the true one.\n      const { min, max } = bounds(),\n        shown = x.get();\n      select(to, { velocity: shown >= min && shown <= max ? velocity : x.getVelocity() });\n    } else if (current.caught) {\n      swallowClick.current = true;\n      select(Math.round(-x.get() / step));\n    } else if (!cancelled && current.locked === null) {\n      const hit = slideAt(event.clientX);\n      if (hit !== null && hit !== target.current) {\n        swallowClick.current = true;\n        select(hit);\n      }\n    } else if (cancelled) {\n      select(target.current);\n    }\n  }\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLElement>) {\n    if (event.altKey || event.ctrlKey || event.metaKey || count < 2) return;\n    const node = event.target as HTMLElement;\n    if (node.closest(\"input, textarea, select, [contenteditable]\")) return;\n    const from = target.current;\n    const to =\n      event.key === \"ArrowLeft\"\n        ? from - 1\n        : event.key === \"ArrowRight\"\n          ? from + 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? last\n              : null;\n    if (to === null) return;\n    event.preventDefault();\n    const next = clamp(to, 0, last);\n    select(next);\n    if (node.getAttribute(\"role\") === \"tab\") tabRefs.current[next]?.focus({ preventScroll: true });\n    else if (trackRef.current?.contains(node)) viewportRef.current?.focus({ preventScroll: true });\n  }\n\n  function toggleRotation() {\n    const drain = drains[index];\n    if (!playing) {\n      setPlaying(true);\n      return;\n    }\n    setPlaying(false);\n    // Pausing refills the pill: this slide stays until the viewer moves on.\n    if (drain) {\n      if (reduced) drain.jump(0);\n      else animate(drain, 0, { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] });\n    }\n  }\n\n  const rootStyle = { \"--carousel-slide\": slideSize, \"--carousel-start\": startIndex } as CSSProperties;\n\n  // The root is the size container, so slide widths, centering padding, and the control row all share one measure. It needs a definite width.\n  return (\n    // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the carousel region delegates arrow, Home and End keys from its viewport and tabs\n    <section\n      ref={rootRef}\n      className={cn(\n        \"@container grid w-full min-w-0 grid-cols-[minmax(0,1fr)] gap-4 [--carousel-gap:var(--space-4)]\",\n        className,\n      )}\n      style={rootStyle}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      onKeyDown={onKeyDown}\n      onFocus={(event) => setFocusPaused(event.target !== playRef.current && event.target.matches(\":focus-visible\"))}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusPaused(false);\n      }}\n    >\n      {/* Only the horizontal axis clips, so focus rings inside the active slide are never cut off at the top or bottom. */}\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the slide viewport is a focusable drag surface moved with the arrow keys; <fieldset> would add form semantics */}\n      <div\n        ref={viewportRef}\n        className=\"relative min-w-0 cursor-grab touch-pan-y overflow-x-clip overflow-y-visible rounded-panel [-webkit-tap-highlight-color:transparent] focus-visible:[--focus-outline-offset:4px] data-dragging:cursor-grabbing\"\n        role=\"group\"\n        aria-label=\"Slides\"\n        // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- the slide viewport is a focusable drag surface moved with the arrow keys; <fieldset> would add form semantics\n        tabIndex={0}\n        data-dragging={dragging || undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={(event) => finish(event, false)}\n        onPointerCancel={(event) => finish(event, true)}\n        onPointerEnter={(event) => {\n          if (event.pointerType === \"mouse\") setHovered(true);\n        }}\n        onPointerLeave={() => setHovered(false)}\n        onClickCapture={(event) => {\n          if (swallowClick.current) {\n            event.preventDefault();\n            event.stopPropagation();\n            swallowClick.current = false;\n          }\n        }}\n        onDragStart={(event) => event.preventDefault()}\n      >\n        {/* Side padding centers the first and last slides; before hydration the start slide is placed with CSS so nothing moves on first paint. */}\n        <motion.div\n          ref={trackRef}\n          className=\"flex gap-(--carousel-gap) px-[calc((100cqw_-_var(--carousel-slide))/2)] select-none not-data-measured:[translate:calc(var(--carousel-start)_*_-1_*_(var(--carousel-slide)_+_var(--carousel-gap)))_0]\"\n          style={{ x }}\n        >\n          {slides.map((slide, i) => (\n            <Slide\n              key={isValidElement(slide) && slide.key != null ? slide.key : i}\n              id={`${id}-slide-${i}`}\n              index={i}\n              progress={progress}\n              active={i === index}\n              label={slideLabel(i, count)}\n            >\n              {slide}\n            </Slide>\n          ))}\n        </motion.div>\n      </div>\n      {/* Controls line up with the edges of the centered slide; on phone widths the row tightens so play, indicators, and arrows still fit beside a narrow slide. */}\n      {count > 1 && (\n        <div className=\"flex min-w-0 items-center gap-2 px-[calc((100cqw_-_var(--carousel-slide))/2)] @max-[340px]:gap-1 @max-[340px]:px-[max(0px,calc((100cqw_-_var(--carousel-slide))/2_-_var(--space-2)))]\">\n          {canRotate && (\n            <button\n              ref={playRef}\n              type=\"button\"\n              className={control}\n              aria-label={playing ? \"Pause slide rotation\" : \"Start slide rotation\"}\n              onClick={toggleRotation}\n            >\n              <span className=\"grid size-4 place-items-center\">\n                <AnimatePresence initial={false}>\n                  <motion.span\n                    key={playing ? \"pause\" : \"play\"}\n                    className=\"col-start-1 row-start-1 grid place-items-center\"\n                    initial={reduced ? { opacity: 0 } : iconIn}\n                    animate={iconRest}\n                    exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : iconOut}\n                    transition={reduced ? { duration: motionTokens.duration.instant } : iconEnter}\n                  >\n                    {playing ? (\n                      <Pause size={15} strokeWidth={1.75} fill=\"currentColor\" aria-hidden=\"true\" />\n                    ) : (\n                      <Play size={15} strokeWidth={1.75} fill=\"currentColor\" aria-hidden=\"true\" />\n                    )}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </button>\n          )}\n          <div className=\"flex min-w-0 items-center\" role=\"tablist\" aria-label=\"Choose a slide\">\n            {slides.map((_, i) => (\n              <Dot\n                key={i}\n                index={i}\n                last={last}\n                progress={progress}\n                drain={drains[i]!}\n                selected={i === index}\n                label={slideLabel(i, count)}\n                controls={`${id}-slide-${i}`}\n                tabRef={(node) => {\n                  tabRefs.current[i] = node;\n                }}\n                onSelect={() => select(i)}\n              />\n            ))}\n          </div>\n          <div className=\"ml-auto flex flex-none gap-2 @max-[340px]:gap-1\">\n            <button\n              type=\"button\"\n              className={control}\n              aria-label=\"Previous slide\"\n              aria-disabled={index === 0}\n              onClick={() => {\n                if (target.current > 0) select(target.current - 1);\n              }}\n            >\n              <ChevronLeft size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n            </button>\n            <button\n              type=\"button\"\n              className={control}\n              aria-label=\"Next slide\"\n              aria-disabled={index === last}\n              onClick={() => {\n                if (target.current < last) select(target.current + 1);\n              }}\n            >\n              <ChevronRight size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n            </button>\n          </div>\n        </div>\n      )}\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </section>\n  );\n}\n\n/** A slide shrinks and dims with its distance from the center, read straight from the track position so it never lags the finger. */\nfunction Slide({\n  id,\n  index,\n  progress,\n  active,\n  label,\n  children,\n}: {\n  id: string;\n  index: number;\n  progress: MotionValue<number>;\n  active: boolean;\n  label: string;\n  children: ReactNode;\n}) {\n  const scale = useTransform(progress, [index - 1, index, index + 1], [SCALE, 1, SCALE]);\n  const opacity = useTransform(progress, [index - 1, index, index + 1], [DIM, 1, DIM]);\n  return (\n    <motion.div\n      id={id}\n      className=\"min-w-0 flex-[0_0_var(--carousel-slide)] origin-center will-change-[transform,opacity] [&_img]:[-webkit-user-drag:none]\"\n      role=\"tabpanel\"\n      aria-roledescription=\"slide\"\n      aria-label={label}\n      inert={!active}\n      style={{ scale, opacity }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\n/** Each indicator grows toward a pill as the track nears its slide, so the pill hands over continuously instead of hopping. */\nfunction Dot({\n  index,\n  last,\n  progress,\n  drain,\n  selected,\n  label,\n  controls,\n  tabRef,\n  onSelect,\n}: {\n  index: number;\n  last: number;\n  progress: MotionValue<number>;\n  drain: MotionValue<number>;\n  selected: boolean;\n  label: string;\n  controls: string;\n  tabRef: Ref<HTMLButtonElement>;\n  onSelect: () => void;\n}) {\n  const emphasis = useTransform(progress, (value) => Math.max(0, 1 - Math.abs(clamp(value, 0, last) - index)));\n  const width = useTransform(emphasis, (value) => DOT + (PILL - DOT) * value);\n  return (\n    <button\n      ref={tabRef}\n      type=\"button\"\n      role=\"tab\"\n      className=\"group/tab grid h-control-sm flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent px-[5px] [-webkit-tap-highlight-color:transparent] focus-visible:[--focus-outline-offset:-4px] @max-[340px]:px-1\"\n      aria-selected={selected}\n      aria-controls={controls}\n      aria-label={label}\n      tabIndex={selected ? 0 : -1}\n      onClick={onSelect}\n    >\n      {/* Resting dots use the strong border color; the fill fades in as the pill forms, and the drain covers it from the right while rotation runs. */}\n      <motion.span\n        className=\"relative block size-1.5 overflow-hidden rounded-button bg-line-strong transition-[background-color] duration-fast ease-standard group-hover/tab:bg-ink-3 motion-reduce:transition-none\"\n        style={{ width }}\n        aria-hidden=\"true\"\n      >\n        <motion.span className=\"absolute inset-0 bg-ink\" style={{ opacity: emphasis }} />\n        <motion.span className=\"absolute inset-0 origin-right bg-line-strong\" style={{ scaleX: drain }} />\n      </motion.span>\n    </button>\n  );\n}\n\nexport default Carousel;\n",
      "type": "registry:component",
      "target": "@components/iris/carousel/carousel.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/carousel",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "Carousel",
    "tags": [
      "carousel",
      "slider",
      "gallery"
    ],
    "docs": "https://irisui.dev/components/carousel"
  }
}
