{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/carousel/carousel.tsx",
      "type": "registry:component",
      "target": "@components/xzero/carousel/carousel.tsx",
      "content": "\"use client\";\n\nimport { Children, isValidElement, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, Ref } from \"react\";\nimport { AnimatePresence, animate, motion, motionValue, useMotionValue, useReducedMotion, useTransform } 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 styles from \"./carousel.module.css\";\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\ntype Drag = { pointer: number; startX: number; startY: number; origin: number; from: number; locked: \"x\" | \"y\" | null; caught: boolean; samples: [number, number][] };\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, PILL = 22;\n/** Scale and opacity of a slide one full step from the center. */\nconst SCALE = .9, DIM = .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 = .995) => velocity / 1000 * rate / (1 - rate);\n/** Past an edge every pixel costs more, like pulling against elastic. */\nconst rubber = (overshoot: number, dimension: number) => overshoot * dimension * .55 / (dimension + .55 * overshoot);\nconst unrubber = (distance: number, dimension: number) => distance * dimension / (.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: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconRest: TargetAndTransition = { opacity: 1, scale: 1, filter: \"blur(0px)\" };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\nconst iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } 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]!, [lastTime, lastX] = recent[recent.length - 1]!;\n  return lastTime > firstTime ? (lastX - firstX) / ((lastTime - firstTime) / 1000) : 0;\n}\n\nexport function Carousel({ label, children, index: controlledIndex, defaultIndex = 0, onIndexChange, slideSize = \"min(80cqw, 340px)\", slideLabel = defaultSlideLabel, interval, autoplay = false, className }: 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(subscribeNothing, () => true, () => false);\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(), 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(), 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) { x.jump(-to * step); return; }\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, 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) => { const rect = node.getBoundingClientRect(); return rect.left + rect.width / 2; };\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) { travel.current?.stop(); travel.current = null; x.jump(-target.current * step); progress.set(target.current); }\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(() => 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) => { if (i !== target.current && Math.abs(position - i) >= 1 && drain.get() !== 0) drain.jump(0); });\n  }), [x, progress, drains]);\n\n  const followIndexProp = useEffectEvent((next: number) => { if (target.current !== next) { target.current = next; glide(next); } });\n  useEffect(() => { followIndexProp(index); }, [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, { duration: (1 - drain.get()) * interval / 1000, ease: \"linear\", onComplete: () => advance() });\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 = typeof IntersectionObserver === \"undefined\" ? null : new IntersectionObserver(([entry]) => { if (entry) setInView(entry.isIntersecting); });\n    observer?.observe(root);\n    const onVisibility = () => setPageVisible(document.visibilityState !== \"hidden\");\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => { observer?.disconnect(); document.removeEventListener(\"visibilitychange\", onVisibility); };\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) { state.active = true; travel.current?.stop(); travel.current = null; state.raw = unband(x.get()); }\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(() => { state.active = false; select(Math.round(-x.get() / metrics.current.step), { velocity: x.getVelocity() }); }, 120);\n  });\n  useEffect(() => {\n    const viewport = viewportRef.current, state = wheel.current;\n    if (!viewport) return;\n    const listener = (event: WheelEvent) => onWheel(event);\n    viewport.addEventListener(\"wheel\", listener, { passive: false });\n    return () => { viewport.removeEventListener(\"wheel\", listener); window.clearTimeout(state.timer); };\n  }, []);\n\n  function slideAt(clientX: number) {\n    const nodes = Array.from(trackRef.current?.children ?? []);\n    const hit = nodes.findIndex(node => { const rect = node.getBoundingClientRect(); return clientX >= rect.left && clientX <= rect.right; });\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)) 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 = travel.current !== null && x.isAnimating() && Math.abs(x.get() + target.current * metrics.current.step) > 12;\n    if (caught) { travel.current?.stop(); travel.current = null; }\n    drag.current = { pointer: event.pointerId, startX: event.clientX, startY: event.clientY, origin: 0, from: target.current, locked: null, caught, samples: [] };\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, dy = event.clientY - current.startY;\n      if (Math.abs(dx) < SLOP && Math.abs(dy) < SLOP) return;\n      if (Math.abs(dy) > Math.abs(dx)) { current.locked = \"y\"; return; }\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) { const direction = velocity < 0 ? 1 : -1; if ((to - current.from) * direction < 1) to = current.from + direction; }\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(), 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) { swallowClick.current = true; select(hit); }\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 = event.key === \"ArrowLeft\" ? from - 1 : event.key === \"ArrowRight\" ? from + 1 : event.key === \"Home\" ? 0 : event.key === \"End\" ? last : 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) { setPlaying(true); return; }\n    setPlaying(false);\n    // Pausing refills the pill: this slide stays until the viewer moves on.\n    if (drain) { if (reduced) drain.jump(0); else animate(drain, 0, { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] }); }\n  }\n\n  const rootStyle = { \"--carousel-slide\": slideSize, \"--carousel-start\": startIndex } as CSSProperties;\n\n  return <section ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} style={rootStyle} aria-roledescription=\"carousel\" aria-label={label} onKeyDown={onKeyDown}\n    onFocus={event => setFocusPaused(event.target !== playRef.current && event.target.matches(\":focus-visible\"))}\n    onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusPaused(false); }}>\n    <div ref={viewportRef} className={styles.viewport} role=\"group\" aria-label=\"Slides\" tabIndex={0} data-dragging={dragging || undefined}\n      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={event => finish(event, false)} onPointerCancel={event => finish(event, true)}\n      onPointerEnter={event => { if (event.pointerType === \"mouse\") setHovered(true); }} onPointerLeave={() => setHovered(false)}\n      onClickCapture={event => { if (swallowClick.current) { event.preventDefault(); event.stopPropagation(); swallowClick.current = false; } }}\n      onDragStart={event => event.preventDefault()}>\n      <motion.div ref={trackRef} className={styles.track} style={{ x }}>\n        {slides.map((slide, i) => <Slide key={isValidElement(slide) && slide.key != null ? slide.key : i} id={`${id}-slide-${i}`} index={i} progress={progress} active={i === index} label={slideLabel(i, count)}>{slide}</Slide>)}\n      </motion.div>\n    </div>\n    {count > 1 && <div className={styles.controls}>\n      {canRotate && <button ref={playRef} type=\"button\" className={styles.control} aria-label={playing ? \"Pause slide rotation\" : \"Start slide rotation\"} onClick={toggleRotation}>\n        <span className={styles.iconSlot}><AnimatePresence initial={false}>\n          <motion.span key={playing ? \"pause\" : \"play\"} className={styles.icon} initial={reduced ? { opacity: 0 } : iconIn} animate={iconRest} exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : iconOut} transition={reduced ? { duration: motionTokens.duration.instant } : iconEnter}>\n            {playing ? <Pause size={15} strokeWidth={1.75} fill=\"currentColor\" aria-hidden=\"true\" /> : <Play size={15} strokeWidth={1.75} fill=\"currentColor\" aria-hidden=\"true\" />}\n          </motion.span>\n        </AnimatePresence></span>\n      </button>}\n      <div className={styles.tabs} role=\"tablist\" aria-label=\"Choose a slide\">\n        {slides.map((_, i) => <Dot key={i} index={i} last={last} progress={progress} drain={drains[i]!} selected={i === index} label={slideLabel(i, count)} controls={`${id}-slide-${i}`}\n          tabRef={node => { tabRefs.current[i] = node; }} onSelect={() => select(i)} />)}\n      </div>\n      <div className={styles.arrows}>\n        <button type=\"button\" className={styles.control} aria-label=\"Previous slide\" aria-disabled={index === 0} onClick={() => { if (target.current > 0) select(target.current - 1); }}><ChevronLeft size={18} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n        <button type=\"button\" className={styles.control} aria-label=\"Next slide\" aria-disabled={index === last} onClick={() => { if (target.current < last) select(target.current + 1); }}><ChevronRight size={18} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n      </div>\n    </div>}\n    <span className={styles.visuallyHidden} aria-live=\"polite\" aria-atomic=\"true\">{announcement}</span>\n  </section>;\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({ id, index, progress, active, label, children }: { id: string; index: number; progress: MotionValue<number>; active: boolean; label: string; children: ReactNode }) {\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 <motion.div id={id} className={styles.slide} role=\"tabpanel\" aria-roledescription=\"slide\" aria-label={label} inert={!active} style={{ scale, opacity }}>{children}</motion.div>;\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({ index, last, progress, drain, selected, label, controls, tabRef, onSelect }: { index: number; last: number; progress: MotionValue<number>; drain: MotionValue<number>; selected: boolean; label: string; controls: string; tabRef: Ref<HTMLButtonElement>; onSelect: () => void }) {\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 <button ref={tabRef} type=\"button\" role=\"tab\" className={styles.tab} aria-selected={selected} aria-controls={controls} aria-label={label} tabIndex={selected ? 0 : -1} onClick={onSelect}>\n    <motion.span className={styles.dot} style={{ width }} aria-hidden=\"true\">\n      <motion.span className={styles.dotFill} style={{ opacity: emphasis }} />\n      <motion.span className={styles.dotDrain} style={{ scaleX: drain }} />\n    </motion.span>\n  </button>;\n}\n\nexport default Carousel;\n"
    },
    {
      "path": "registry/components/carousel/carousel.module.css",
      "type": "registry:component",
      "target": "@components/xzero/carousel/carousel.module.css",
      "content": "/* 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.root { --carousel-gap: var(--space-4); display: grid; width: 100%; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: var(--space-4); container-type: inline-size; }\n/* Only the horizontal axis clips, so focus rings inside the active slide are never cut off at the top or bottom. */\n.viewport { position: relative; min-width: 0; overflow-x: clip; overflow-y: visible; border-radius: var(--radius-panel); cursor: grab; touch-action: pan-y; -webkit-tap-highlight-color: transparent; }\n.viewport[data-dragging] { cursor: grabbing; }\n.viewport:focus-visible { --focus-outline-offset: 4px; }\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.track { display: flex; gap: var(--carousel-gap); padding-inline: calc((100cqw - var(--carousel-slide)) / 2); user-select: none; -webkit-user-select: none; }\n.track:not([data-measured]) { translate: calc(var(--carousel-start) * -1 * (var(--carousel-slide) + var(--carousel-gap))) 0; }\n.slide { flex: 0 0 var(--carousel-slide); min-width: 0; transform-origin: 50% 50%; will-change: transform, opacity; }\n.slide img { -webkit-user-drag: none; }\n\n/* Controls line up with the edges of the centered slide. */\n.controls { display: flex; min-width: 0; align-items: center; gap: var(--space-2); padding-inline: calc((100cqw - var(--carousel-slide)) / 2); }\n.tabs { display: flex; min-width: 0; align-items: center; }\n.tab { display: grid; height: var(--control-height-sm); flex: none; place-items: center; border: 0; border-radius: var(--radius-pill); padding: 0 5px; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }\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.dot { position: relative; display: block; width: 6px; height: 6px; overflow: hidden; border-radius: var(--radius-pill); background: var(--border-strong); transition: background-color var(--duration-fast) var(--ease-standard); }\n.dotFill, .dotDrain { position: absolute; inset: 0; }\n.dotFill { background: var(--foreground); }\n.dotDrain { background: var(--border-strong); transform-origin: 100% 50%; }\n@media (hover: hover) and (pointer: fine) { .tab:hover .dot { background: var(--text-muted); } }\n.tab:focus-visible { --focus-outline-offset: -4px; }\n\n.arrows { display: flex; flex: none; gap: var(--space-2); margin-left: auto; }\n.control { display: grid; width: var(--control-height-sm); height: var(--control-height-sm); flex: none; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0; background: var(--surface); color: var(--foreground); cursor: pointer; -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); }\n@media (hover: hover) and (pointer: fine) { .control:hover:not([aria-disabled=\"true\"]) { background: var(--surface-muted); } }\n.control:active:not([aria-disabled=\"true\"]) { transform: scale(.97); transition: transform 100ms var(--ease-standard); }\n.control[aria-disabled=\"true\"] { color: var(--text-muted); cursor: default; opacity: .5; }\n.iconSlot { display: grid; width: 16px; height: 16px; place-items: center; }\n.icon { grid-area: 1 / 1; display: grid; place-items: center; }\n/* On phone widths the row tightens so play, indicators, and arrows still fit beside a narrow slide. */\n@container (max-width: 340px) {\n  .controls { gap: var(--space-1); padding-inline: max(0px, calc((100cqw - var(--carousel-slide)) / 2 - var(--space-2))); }\n  .arrows { gap: var(--space-1); }\n  .tab { padding: 0 4px; }\n}\n.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .dot, .control, .control:active:not([aria-disabled=\"true\"]) { transition: none; transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/carousel",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "carousel",
      "slider",
      "gallery"
    ],
    "docs": "https://ui.xzero.co.uk/components/carousel",
    "markdown": "https://ui.xzero.co.uk/components/carousel/markdown"
  }
}