{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel",
  "type": "registry:ui",
  "title": "Carousel",
  "description": "A row of slides that scrolls sideways with the browser's own scrolling, snaps each slide to the start edge and lets the next one peek. The range in words with Previous and Next, arrow keys on the track and a mouse drag. It never moves on its own.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/carousel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/carousel 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Carousel: a row of slides that scrolls sideways, such as a project's screens or a strip of cards (components.md,\n * \"Carousel\"). Carousel, CarouselContent, CarouselItem and CarouselControls, as shadcn names them, and useCarousel for\n * controls of your own.\n *\n * - Scrolling is the browser's: a scroll-snapped track, so a swipe, a trackpad, Shift and the wheel all move it with\n *   their own momentum, and each slide comes to rest at the start edge. With a mouse the track can also be dragged; it\n *   lets go onto the nearest slide in the way it was going. The scrollbar is hidden, because the controls and the\n *   peeking next slide say there is more.\n * - It never moves on its own: no autoplay, no loop, no timer (foundations.md, Motion: looping autoplay and marquee are\n *   never allowed). The person always moves it, so there is nothing to pause.\n * - Slides per view: one, two or three, with the next slide peeking 48px at the end edge (96px for one slide in a wide\n *   box), or auto for slides that set their own width. The box decides by a container query, not the screen: in a\n *   narrow box every carousel is one slide and a peek.\n * - Controls (CarouselControls): the range in words at the start, \"3 of 12\" or \"4 to 6 of 12\", in tabular figures at the\n *   caption size in --litt-fg-subtle, as Pagination's; and round secondary Previous and Next buttons at the end, 44px on a\n *   touch screen. Next moves a page (the first slide not wholly in view comes to the start), Previous a page back. At\n *   an end the button is disabled and hands its focus to the other one.\n * - Keys: the track is a tab stop. ← and → move one slide, Home and End go to the first and the last.\n * - Motion: a button, a key or a drag's release scrolls smoothly, the browser's own scroll; under reduced motion it\n *   jumps. Nothing else moves, and nothing fades at the edges (material is static, R2).\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): a mouse drag can be thrown. Let go and the track\n *   keeps the speed it had, coasts to the slide that speed would reach (a hard throw goes further than the next one)\n *   and lands on it with fun mode's throw spring. Buttons and keys land on the same spring. Dragged past either end\n *   the track gives like a rubber band and springs back when let go. Touch and trackpads keep the browser's own\n *   momentum. Every landing rests where the snap would put it, so the slides rest exactly where they do without fun\n *   mode. Reduced motion turns it off.\n * - Accessibility: a region named by `label`, with aria-roledescription=\"carousel\"; each slide a group with\n *   aria-roledescription=\"slide\" named \"3 of 12\". Slides out of view stay readable, because scrolling reaches them.\n *   After a button or a key the new range is said once in a polite status; a swipe says nothing.\n * - Flat: the Previous and Next keys are the Button's round grey circles; nothing else changes.\n */\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { animate, type AnimationPlaybackControls } from \"motion/react\";\nimport { useFun, useMotionDial } from \"../../lib/litt/motion\";\nimport { Button } from \"./button\";\nimport { ChevronLeftIcon, ChevronRightIcon } from \"./icons\";\n\nexport type CarouselPerView = \"one\" | \"two\" | \"three\" | \"auto\";\n\n/* The slide widths, by the box's width. Each leaves the peek (3rem, 6rem for one slide in a wide box) plus the gaps. */\nconst BASIS: Record<CarouselPerView, string> = {\n  one: \"basis-[calc(100%-3rem)] @min-[48rem]:basis-[calc(100%-6rem)]\",\n  two: \"basis-[calc(100%-3rem)] @min-[36rem]:basis-[calc((100%-4rem)/2)]\",\n  three: \"basis-[calc(100%-3rem)] @min-[28rem]:basis-[calc((100%-4rem)/2)] @min-[48rem]:basis-[calc((100%-5rem)/3)]\",\n  auto: \"\",\n};\n\n/** What is in view: the first and last slides wholly in view, how many there are, and which ways it can still go. */\nexport type CarouselView = { first: number; last: number; total: number; canPrevious: boolean; canNext: boolean };\n\ntype Ctx = {\n  label: string;\n  perView: CarouselPerView;\n  trackId: string;\n  track: RefObject<HTMLDivElement | null>;\n  view: CarouselView;\n  /** Scrolls slide `index` to the start edge. `announce` says the new range once it rests; `instant` skips the smooth scroll. In fun mode, `velocity` (scroll px/s) is the speed a throw carries into the landing. */\n  goTo: (index: number, announce?: boolean, instant?: boolean, velocity?: number) => void;\n  /** Fun mode: stops a landing in flight, because a hand or a wheel has taken the track. */\n  stop: () => void;\n  /** A page forward: the first slide not wholly in view comes to the start. */\n  next: () => void;\n  /** A page back. */\n  previous: () => void;\n  /** Called by the track on every scroll and resize. */\n  measure: () => void;\n};\n\nconst CarouselContext = createContext<Ctx | null>(null);\nconst ItemContext = createContext<{ index: number; total: number }>({ index: 0, total: 0 });\n\n/** The carousel around it: what is in view, and the moves the controls use. For controls of your own. */\nexport function useCarousel() {\n  const ctx = useContext(CarouselContext);\n  if (!ctx) throw new Error(\"useCarousel needs a Carousel around it.\");\n  const { view, goTo, next, previous, label, trackId } = ctx;\n  return { view, goTo, next, previous, label, trackId };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Geometry                                                                   */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Where every slide sits, in inline terms: px from the track's start edge (inside its padding), so the same sums work\n * left to right and right to left. `pos` is how far the track has scrolled from its start and `max` how far it can.\n */\nfunction geometry(track: HTMLElement) {\n  const cs = getComputedStyle(track);\n  const rtl = cs.direction === \"rtl\";\n  const box = track.getBoundingClientRect();\n  const padStart = parseFloat(rtl ? cs.paddingRight : cs.paddingLeft) || 0;\n  const padEnd = parseFloat(rtl ? cs.paddingLeft : cs.paddingRight) || 0;\n  const start = rtl ? box.right - padStart : box.left + padStart;\n  const width = box.width - padStart - padEnd;\n  const slides = Array.from(track.children).filter((el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute(\"data-carousel-item\"));\n  const spans = slides.map((el) => {\n    const r = el.getBoundingClientRect();\n    return rtl ? { from: start - r.right, to: start - r.left } : { from: r.left - start, to: r.right - start };\n  });\n  return { rtl, width, spans, pos: Math.abs(track.scrollLeft), max: track.scrollWidth - track.clientWidth };\n}\n\nfunction viewOf(track: HTMLElement): CarouselView {\n  const { width, spans, pos, max } = geometry(track);\n  const total = spans.length;\n  const whole = spans.map((s, i) => (s.from >= -1 && s.to <= width + 1 ? i : -1)).filter((i) => i >= 0);\n  let first = whole[0];\n  let last = whole[whole.length - 1];\n  if (first === undefined || last === undefined) {\n    /* A slide wider than the track: the one nearest the start is the one in view. */\n    let near = 0;\n    spans.forEach((s, i) => {\n      if (Math.abs(s.from) < Math.abs(spans[near]!.from)) near = i;\n    });\n    first = last = total ? near : 0;\n  }\n  return { first, last, total, canPrevious: pos > 1, canNext: pos < max - 1 };\n}\n\nconst same = (a: CarouselView, b: CarouselView) =>\n  a.first === b.first && a.last === b.last && a.total === b.total && a.canPrevious === b.canPrevious && a.canNext === b.canNext;\n\n/** \"3 of 12\", or \"4 to 6 of 12\" when several are in view. */\nconst rangeText = (v: CarouselView) => (v.first === v.last ? `${v.first + 1} of ${v.total}` : `${v.first + 1} to ${v.last + 1} of ${v.total}`);\n\n/* -------------------------------------------------------------------------- */\n/* Carousel                                                                   */\n/* -------------------------------------------------------------------------- */\n\nexport type CarouselProps = {\n  /** What the slides are, for screen readers: \"Halo site, screens\". */\n  label: string;\n  /** How many slides fit the box: one, two or three with the next one peeking, or auto when each CarouselItem sets its own width. A narrow box always shows one and a peek. */\n  perView?: CarouselPerView;\n  /** The first slide in view. Pass it with onIndexChange to scroll the carousel from outside, as a Lightbox does when it closes on another image. */\n  index?: number;\n  /** Called with the first slide in view whenever it changes, by any means. */\n  onIndexChange?: (index: number) => void;\n  /** A CarouselContent with the slides, and CarouselControls where you want them (usually after it). */\n  children: ReactNode;\n  /** Extra classes for the carousel, such as a width. */\n  className?: string;\n};\n\n/** The root: the region, what is in view and the moves. Put a CarouselContent and CarouselControls inside it. */\nexport function Carousel({ label, perView = \"one\", index, onIndexChange, children, className = \"\" }: CarouselProps) {\n  const track = useRef<HTMLDivElement | null>(null);\n  const trackId = useId();\n  const { reduced } = useMotionDial();\n  const [view, setView] = useState<CarouselView>({ first: 0, last: 0, total: 0, canPrevious: false, canNext: false });\n  const viewRef = useRef(view);\n  const [status, setStatus] = useState(\"\");\n  const announce = useRef(false);\n  const settle = useRef(0);\n  /* Fun mode: the landing in flight. Snapping is off while it runs, so the spring can pass the slide and come back. */\n  const fun = useFun();\n  const flight = useRef<AnimationPlaybackControls | null>(null);\n  const stop = useCallback(() => {\n    flight.current?.stop();\n    flight.current = null;\n    if (track.current) track.current.style.scrollSnapType = \"\";\n  }, []);\n  useEffect(() => stop, [stop]);\n\n  const measure = useCallback(() => {\n    const el = track.current;\n    if (!el) return;\n    const next = viewOf(el);\n    if (!same(next, viewRef.current)) {\n      viewRef.current = next;\n      setView(next);\n    }\n    /* Said once the scroll has come to rest, and only after a button or a key. */\n    window.clearTimeout(settle.current);\n    settle.current = window.setTimeout(() => {\n      if (!announce.current) return;\n      announce.current = false;\n      const v = viewRef.current;\n      setStatus(`${v.first === v.last ? \"Slide\" : \"Slides\"} ${rangeText(v)}`);\n    }, 150);\n  }, []);\n\n  useEffect(() => () => window.clearTimeout(settle.current), []);\n\n  const goTo = useCallback(\n    (to: number, say = true, instant = false, velocity = 0) => {\n      const el = track.current;\n      if (!el) return;\n      const { rtl, spans, max } = geometry(el);\n      const target = spans[Math.max(0, Math.min(spans.length - 1, to))];\n      if (!target) return;\n      announce.current = say;\n      if (fun && !instant) {\n        /* Fun mode: land on the throw spring, at the scroll the snap would choose (kept inside the track's range). */\n        stop();\n        const from = el.scrollLeft;\n        const end = Math.max(rtl ? -max : 0, Math.min(rtl ? 0 : max, from + (rtl ? -target.from : target.from)));\n        el.style.scrollSnapType = \"none\";\n        const run = animate(from, end, { ...fun.throw, velocity, onUpdate: (v) => (el.scrollLeft = v) });\n        flight.current = run;\n        void run.then(() => flight.current === run && stop());\n        requestAnimationFrame(measure);\n        return;\n      }\n      el.scrollBy({ left: rtl ? -target.from : target.from, behavior: reduced || instant ? \"auto\" : \"smooth\" });\n      /* Nothing to scroll (already there): still say where it is. */\n      requestAnimationFrame(measure);\n    },\n    [reduced, measure, fun, stop],\n  );\n\n  const next = useCallback(() => {\n    const v = viewRef.current;\n    goTo(Math.min(v.total - 1, v.last + 1));\n  }, [goTo]);\n\n  const previous = useCallback(() => {\n    const el = track.current;\n    if (!el) return;\n    const { width, spans } = geometry(el);\n    const v = viewRef.current;\n    const end = spans[v.first - 1];\n    if (!end) return goTo(0);\n    /* Back far enough that the slide before the first one in view becomes the last one in view. */\n    let to = v.first - 1;\n    while (to > 0 && end.to - spans[to - 1]!.from <= width + 1) to--;\n    goTo(to);\n  }, [goTo]);\n\n  /* The last first slide told to onIndexChange. An index that comes back equal to it is that report's echo, even\n     when the track has scrolled on since, so it never pulls a smooth scroll back. */\n  const reported = useRef<number | null>(null);\n\n  /* Controlled: a new index from outside scrolls there (at once on the first render, so the page opens on it). */\n  const mounted = useRef(false);\n  useLayoutEffect(() => {\n    const first = !mounted.current;\n    mounted.current = true;\n    if (index === undefined) return;\n    if (first) {\n      if (index > 0) goTo(index, false, true);\n      return;\n    }\n    if (index !== reported.current && index !== viewRef.current.first) goTo(index, false);\n    // goTo is left out on purpose: only a new index scrolls.\n  }, [index]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (view.total === 0 || reported.current === view.first) return;\n    reported.current = view.first;\n    onIndexChange?.(view.first);\n  }, [view.first, view.total, onIndexChange]);\n\n  return (\n    <CarouselContext value={{ label, perView, trackId, track, view, goTo, next, previous, measure, stop }}>\n      <section aria-roledescription=\"carousel\" aria-label={label} className={\"@container grid min-w-0 gap-4 \" + className}>\n        {children}\n        <p role=\"status\" className=\"sr-only\">\n          {status}\n        </p>\n      </section>\n    </CarouselContext>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* CarouselContent                                                            */\n/* -------------------------------------------------------------------------- */\n\n/* A mouse drag never starts on these: they own the pointer. */\nconst NO_DRAG = \"input, textarea, select, [contenteditable]:not([contenteditable=false]), [role=slider], [data-no-drag]\";\nconst SLOP = 6; // px of travel before a press is a drag, not a click\n\nexport type CarouselContentProps = {\n  /** The slides: one CarouselItem each. */\n  children: ReactNode;\n  /** Extra classes for the track. */\n  className?: string;\n};\n\n/** The track: the slides in a row that scrolls sideways and snaps each slide to the start edge. A tab stop for the arrow keys. */\nexport function CarouselContent({ children, className = \"\" }: CarouselContentProps) {\n  const ctx = useContext(CarouselContext);\n  if (!ctx) throw new Error(\"CarouselContent must sit inside a Carousel.\");\n  const { track, trackId, measure, goTo, view, stop } = ctx;\n  const fun = useFun();\n  const items = Children.toArray(children).filter(isValidElement);\n  const [dragging, setDragging] = useState(false);\n  const frame = useRef(0);\n  /* Fun mode: the rubber band springing back, stopped when a hand takes the track again. */\n  const band = useRef<AnimationPlaybackControls | null>(null);\n\n  /* Measure after every render (slides added or removed), on resize, and on every scroll frame. */\n  useLayoutEffect(() => measure());\n  /* Fun mode: a wheel or a finger takes the track from a landing in flight. */\n  useEffect(() => {\n    const el = track.current;\n    if (!el || !fun) return;\n    el.addEventListener(\"wheel\", stop, { passive: true });\n    el.addEventListener(\"touchstart\", stop, { passive: true });\n    return () => {\n      el.removeEventListener(\"wheel\", stop);\n      el.removeEventListener(\"touchstart\", stop);\n    };\n  }, [track, fun, stop]);\n\n  useEffect(() => {\n    const el = track.current;\n    if (!el) return;\n    const onScroll = () => {\n      cancelAnimationFrame(frame.current);\n      frame.current = requestAnimationFrame(measure);\n    };\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(() => measure());\n    ro?.observe(el);\n    return () => {\n      el.removeEventListener(\"scroll\", onScroll);\n      ro?.disconnect();\n      cancelAnimationFrame(frame.current);\n    };\n  }, [track, measure]);\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.target !== e.currentTarget || e.altKey || e.ctrlKey || e.metaKey) return;\n    const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const forward = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const back = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    const to =\n      e.key === forward ? view.first + 1 : e.key === back ? view.first - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? view.total - 1 : null;\n    if (to === null) return;\n    e.preventDefault();\n    goTo(Math.max(0, Math.min(view.total - 1, to)));\n  };\n\n  /* A mouse drags the track; touch and trackpads already scroll it natively. Snapping is off while the mouse holds\n     it, then the release scrolls to the nearest slide in the way it was going and snapping comes back. */\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const el = track.current;\n    if (!el || e.pointerType !== \"mouse\" || e.button !== 0) return;\n    if ((e.target as Element).closest(NO_DRAG)) return;\n    if (fun) return throwDrag(el, e);\n    const x0 = e.clientX;\n    const left0 = el.scrollLeft;\n    let moved = false;\n    let dx = 0;\n\n    const move = (ev: PointerEvent) => {\n      dx = ev.clientX - x0;\n      if (!moved) {\n        if (Math.abs(dx) < SLOP) return;\n        moved = true;\n        el.style.scrollSnapType = \"none\";\n        setDragging(true);\n        window.getSelection()?.removeAllRanges();\n      }\n      el.scrollLeft = left0 - dx;\n    };\n    const up = () => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      if (!moved) return;\n      setDragging(false);\n      /* The click that ends a drag is not a press on whatever is under the pointer. */\n      const swallow = (ev: Event) => {\n        ev.stopPropagation();\n        ev.preventDefault();\n      };\n      window.addEventListener(\"click\", swallow, { capture: true, once: true });\n      setTimeout(() => window.removeEventListener(\"click\", swallow, true), 0);\n\n      const { rtl, spans } = geometry(el);\n      let near = 0;\n      spans.forEach((s, i) => {\n        if (Math.abs(s.from) < Math.abs(spans[near]!.from)) near = i;\n      });\n      const forward = rtl ? dx > 0 : dx < 0;\n      const at = spans[near]?.from ?? 0;\n      const to = forward && at < -8 ? near + 1 : !forward && at > 8 ? near - 1 : near;\n      goTo(Math.max(0, Math.min(spans.length - 1, to)), false);\n      const restore = () => {\n        el.style.scrollSnapType = \"\";\n      };\n      if (\"onscrollend\" in el) el.addEventListener(\"scrollend\", restore, { once: true });\n      setTimeout(restore, 700); // a browser without scrollend, or a scroll that never started\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  };\n\n  /* Fun mode's mouse drag: the track follows the hand, gives like a rubber band past either end, and is thrown on\n     release: the speed it had picks the slide it coasts to, and the landing carries that speed. */\n  const throwDrag = (el: HTMLDivElement, e: ReactPointerEvent<HTMLDivElement>) => {\n    if (!fun) return;\n    stop();\n    band.current?.stop();\n    el.style.translate = \"\";\n    const x0 = e.clientX;\n    const left0 = el.scrollLeft;\n    const { rtl, max } = geometry(el);\n    const [lo, hi] = rtl ? [-max, 0] : [0, max];\n    let moved = false;\n    let trail: { t: number; left: number }[] = [];\n\n    const move = (ev: PointerEvent) => {\n      const dx = ev.clientX - x0;\n      if (!moved) {\n        if (Math.abs(dx) < SLOP) return;\n        moved = true;\n        el.style.scrollSnapType = \"none\";\n        setDragging(true);\n        window.getSelection()?.removeAllRanges();\n      }\n      const want = left0 - dx;\n      const left = Math.max(lo, Math.min(hi, want));\n      el.scrollLeft = left;\n      el.style.translate = want === left ? \"\" : `${-fun.rubber(want - left)}px`;\n      trail.push({ t: ev.timeStamp, left: want });\n      trail = trail.filter((p) => ev.timeStamp - p.t <= 100);\n    };\n    const up = (ev: PointerEvent) => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      if (!moved) return;\n      setDragging(false);\n      const swallow = (c: Event) => {\n        c.stopPropagation();\n        c.preventDefault();\n      };\n      window.addEventListener(\"click\", swallow, { capture: true, once: true });\n      setTimeout(() => window.removeEventListener(\"click\", swallow, true), 0);\n\n      /* Past an end: the band springs back. */\n      const pulled = parseFloat(el.style.translate) || 0;\n      if (pulled) band.current = animate(pulled, 0, { ...fun.throw, onUpdate: (v) => (el.style.translate = v ? `${v}px` : \"\") });\n\n      /* The release speed in scroll px/s (zero if the hand rested), then the slide nearest where it would coast to. */\n      const first = trail[0];\n      const last = trail[trail.length - 1];\n      const fresh = last && ev.timeStamp - last.t <= 100;\n      const velocity = fresh && last.t > first!.t ? ((last.left - first!.left) / (last.t - first!.t)) * 1000 : 0;\n      const ahead = (rtl ? -1 : 1) * fun.coast(velocity);\n      const { spans } = geometry(el);\n      let to = 0;\n      spans.forEach((s, i) => {\n        if (Math.abs(s.from - ahead) < Math.abs(spans[to]!.from - ahead)) to = i;\n      });\n      goTo(to, false, false, pulled ? 0 : velocity);\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  };\n\n  return (\n    <div\n      ref={track}\n      id={trackId}\n      role=\"group\"\n      aria-label=\"Slides\"\n      tabIndex={0}\n      onKeyDown={onKeyDown}\n      onPointerDown={onPointerDown}\n      onDragStart={(e) => e.preventDefault()}\n      className={[\n        /* 4px of room on every side, so a slide's focus ring is never cut by the scroll box. */\n        \"-m-1 flex min-w-0 snap-x snap-mandatory scroll-p-1 gap-4 overflow-x-auto overflow-y-hidden overscroll-x-contain p-1\",\n        \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n        \"rounded-litt-surface focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\",\n        dragging ? \"cursor-grabbing select-none\" : \"\",\n        className,\n      ].join(\" \")}\n    >\n      {items.map((child, i) => (\n        <ItemContext key={child.key ?? i} value={{ index: i, total: items.length }}>\n          {child}\n        </ItemContext>\n      ))}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* CarouselItem                                                               */\n/* -------------------------------------------------------------------------- */\n\nexport type CarouselItemProps = {\n  /** A name for the slide, added to its number for screen readers: \"Home page\" reads \"3 of 12: Home page\". */\n  label?: string;\n  /** The slide's content: a Media, a Card, anything. */\n  children: ReactNode;\n  /** Extra classes for the slide. With perView=\"auto\", set its width here (w-64). */\n  className?: string;\n};\n\n/** One slide. Its width comes from the carousel's perView, or from className when perView is auto. */\nexport function CarouselItem({ label, children, className = \"\" }: CarouselItemProps) {\n  const ctx = useContext(CarouselContext);\n  const { index, total } = useContext(ItemContext);\n  const name = `${index + 1} of ${total}${label ? `: ${label}` : \"\"}`;\n  return (\n    <div\n      data-carousel-item=\"\"\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={name}\n      className={\"min-w-0 shrink-0 snap-start \" + BASIS[ctx?.perView ?? \"one\"] + \" \" + className}\n    >\n      {children}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* CarouselControls                                                           */\n/* -------------------------------------------------------------------------- */\n\nconst ROUND = \"pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\";\n\nexport type CarouselControlsProps = {\n  /** Extra classes for the row. */\n  className?: string;\n};\n\n/** The range in words and the Previous and Next buttons, in one row. Put it after the CarouselContent. */\nexport function CarouselControls({ className = \"\" }: CarouselControlsProps) {\n  const ctx = useContext(CarouselContext);\n  if (!ctx) throw new Error(\"CarouselControls must sit inside a Carousel.\");\n  const { view, next, previous, trackId } = ctx;\n  const prevRef = useRef<HTMLButtonElement>(null);\n  const nextRef = useRef<HTMLButtonElement>(null);\n  const several = view.first !== view.last;\n\n  /* A pressed button that has just been disabled hands its focus to the other one. The browser may already have\n     dropped focus to the body when the button turned disabled, so the last press counts too. */\n  const pressed = useRef<\"previous\" | \"next\" | null>(null);\n  useEffect(() => {\n    const lost = (which: \"previous\" | \"next\", el: HTMLButtonElement | null) =>\n      document.activeElement === el || (pressed.current === which && (document.activeElement === document.body || document.activeElement === null));\n    if (!view.canNext && lost(\"next\", nextRef.current)) prevRef.current?.focus();\n    if (!view.canPrevious && lost(\"previous\", prevRef.current)) nextRef.current?.focus();\n  }, [view.canNext, view.canPrevious]);\n\n  return (\n    <div className={\"flex items-center justify-between gap-4 \" + className}>\n      <p aria-hidden=\"true\" className=\"text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n        {view.total > 0 ? rangeText(view) : \"\"}\n      </p>\n      <div className=\"flex items-center gap-2\">\n        <Button\n          ref={prevRef}\n          iconOnly\n          size=\"sm\"\n          aria-label={several ? \"Previous slides\" : \"Previous slide\"}\n          aria-controls={trackId}\n          disabled={!view.canPrevious}\n          onClick={() => {\n            pressed.current = \"previous\";\n            previous();\n          }}\n          onBlur={() => {\n            pressed.current = null;\n          }}\n          className={ROUND}\n        >\n          <ChevronLeftIcon className=\"rtl:-scale-x-100\" />\n        </Button>\n        <Button\n          ref={nextRef}\n          iconOnly\n          size=\"sm\"\n          aria-label={several ? \"Next slides\" : \"Next slide\"}\n          aria-controls={trackId}\n          disabled={!view.canNext}\n          onClick={() => {\n            pressed.current = \"next\";\n            next();\n          }}\n          onBlur={() => {\n            pressed.current = null;\n          }}\n          className={ROUND}\n        >\n          <ChevronRightIcon className=\"rtl:-scale-x-100\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/carousel.tsx"
    }
  ],
  "docs": "Import Carousel, CarouselContent, CarouselItem and CarouselControls from components/litt/carousel, as shadcn's carousel is written. Give Carousel a label and perView; never add autoplay.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
