{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media-carousel",
  "type": "registry:ui",
  "title": "Media carousel",
  "description": "Keeps native drag inertia, deliberate snap positions, and keyboard navigation across media cards.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "cards-media"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/media-carousel/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Media rail driven by native scroll inertia and snap",
    "sceneFamily": "editorial-warm",
    "motionRole": "gentle",
    "primaryState": "Media carousel with a centered focal card",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/media-carousel.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/media-carousel.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { useReducedMotion } from \"motion/react\";\n\nexport type MediaCarouselItem = {\n  id: string;\n  title: string;\n  eyebrow?: string;\n  description?: string;\n  meta?: string;\n  art: ReactNode;\n};\n\nexport type MediaCarouselProps = {\n  items: readonly MediaCarouselItem[];\n  label?: string;\n  copy?: Partial<MediaCarouselCopy>;\n  initialIndex?: number;\n  className?: string;\n  onSelect?: (item: MediaCarouselItem, index: number) => void;\n};\n\nexport type MediaCarouselCopy = {\n  collection: string;\n  emptyCollection: string;\n  previousSlide: string;\n  nextSlide: string;\n  carouselRole: string;\n  slideRole: string;\n  position: (index: number, total: number) => string;\n  instructions: string;\n};\n\nconst DEFAULT_COPY: MediaCarouselCopy = {\n  collection: \"Collection\",\n  emptyCollection: \"No slides available.\",\n  previousSlide: \"Previous slide\",\n  nextSlide: \"Next slide\",\n  carouselRole: \"carousel\",\n  slideRole: \"slide\",\n  position: (index, total) => `${index} of ${total}`,\n  instructions: \"Swipe or scroll through the slides. Use Left and Right Arrow, Home, or End from the carousel to move between slides.\",\n};\n\nconst clampIndex = (value: number, length: number) =>\n  Math.min(Math.max(value, 0), Math.max(0, length - 1));\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nconst PREVIOUS_ICON = (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden=\"true\">\n    <path\n      d=\"m11.8 5.2-4.6 4.8 4.6 4.8\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.6\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst NEXT_ICON = (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden=\"true\">\n    <path\n      d=\"m8.2 5.2 4.6 4.8-4.6 4.8\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.6\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nexport function MediaCarousel({\n  items,\n  label = \"Featured stories\",\n  copy: copyOverrides,\n  initialIndex = 0,\n  className = \"\",\n  onSelect,\n}: MediaCarouselProps) {\n  const copy = { ...DEFAULT_COPY, ...copyOverrides };\n  const reduced = useReducedMotion() === true;\n  const titleId = useId();\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const slideRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const scrollFrameRef = useRef<number | null>(null);\n  const initialPositionedRef = useRef(false);\n  const itemIdSequence = JSON.stringify(items.map((item) => item.id));\n  const positionedItemIdSequenceRef = useRef(itemIdSequence);\n  const initialActiveIndex = items.length > 0\n    ? clampIndex(initialIndex, items.length)\n    : -1;\n  const [activeId, setActiveId] = useState<string | null>(\n    () => items[initialActiveIndex]?.id ?? null,\n  );\n  const activeIdRef = useRef(activeId);\n  const activeIndexRef = useRef(initialActiveIndex);\n  const matchedActiveIndex = activeId === null\n    ? -1\n    : items.findIndex((item) => item.id === activeId);\n  const activeIndex = matchedActiveIndex >= 0\n    ? matchedActiveIndex\n    : items.length > 0\n      ? activeId === null\n        ? initialActiveIndex\n        : clampIndex(activeIndexRef.current, items.length)\n      : -1;\n  const activeItemId = items[activeIndex]?.id ?? null;\n\n  useIsomorphicLayoutEffect(() => {\n    if (items.length === 0) {\n      initialPositionedRef.current = false;\n      positionedItemIdSequenceRef.current = itemIdSequence;\n      slideRefs.current = [];\n      activeIdRef.current = null;\n      activeIndexRef.current = -1;\n      if (activeId !== null) setActiveId(null);\n      return;\n    }\n    if (activeIndex < 0 || activeItemId === null) return;\n\n    const previousActiveIndex = activeIndexRef.current;\n    const itemOrderChanged = positionedItemIdSequenceRef.current !== itemIdSequence;\n    const retainedActiveItemMoved = activeId === activeItemId\n      && itemOrderChanged\n      && previousActiveIndex !== activeIndex;\n    positionedItemIdSequenceRef.current = itemIdSequence;\n\n    if (activeId === null) {\n      activeIdRef.current = activeItemId;\n      activeIndexRef.current = activeIndex;\n      setActiveId(activeItemId);\n    } else if (activeId === activeItemId) {\n      activeIndexRef.current = activeIndex;\n    }\n    if (initialPositionedRef.current && !retainedActiveItemMoved) return;\n\n    const viewport = viewportRef.current;\n    const slide = slideRefs.current[activeIndex];\n    if (!viewport || !slide) return;\n\n    viewport.scrollLeft = slide.offsetLeft - viewport.offsetLeft;\n    initialPositionedRef.current = true;\n  }, [activeId, activeIndex, activeItemId, itemIdSequence, items.length]);\n\n  useEffect(() => {\n    if (activeId === null || matchedActiveIndex >= 0 || items.length === 0) return;\n\n    const index = clampIndex(activeIndexRef.current, items.length);\n    const item = items[index];\n    activeIdRef.current = item.id;\n    activeIndexRef.current = index;\n    setActiveId(item.id);\n    onSelect?.(item, index);\n  }, [activeId, items, matchedActiveIndex, onSelect]);\n\n  useEffect(\n    () => () => {\n      if (scrollFrameRef.current !== null) {\n        cancelAnimationFrame(scrollFrameRef.current);\n      }\n    },\n    [],\n  );\n\n  const selectIndex = useCallback(\n    (nextIndex: number) => {\n      if (items.length === 0) return;\n      const index = clampIndex(nextIndex, items.length);\n      const item = items[index];\n      if (activeIdRef.current === item.id) {\n        activeIndexRef.current = index;\n        return;\n      }\n      activeIdRef.current = item.id;\n      activeIndexRef.current = index;\n      setActiveId(item.id);\n      onSelect?.(item, index);\n    },\n    [items, onSelect],\n  );\n\n  const goTo = useCallback(\n    (nextIndex: number, animate: boolean, focus = false) => {\n      if (items.length === 0) return;\n      const index = clampIndex(nextIndex, items.length);\n      const viewport = viewportRef.current;\n      const slide = slideRefs.current[index];\n      if (!viewport || !slide) return;\n\n      viewport.scrollTo({\n        left: slide.offsetLeft - viewport.offsetLeft,\n        behavior: animate && !reduced ? \"smooth\" : \"auto\",\n      });\n      selectIndex(index);\n      if (focus) slide.focus({ preventScroll: true });\n    },\n    [items.length, reduced, selectIndex],\n  );\n\n  const updateFromScroll = () => {\n    if (scrollFrameRef.current !== null) return;\n    scrollFrameRef.current = requestAnimationFrame(() => {\n      scrollFrameRef.current = null;\n      const viewport = viewportRef.current;\n      if (!viewport || items.length === 0) return;\n      const center = viewport.scrollLeft + viewport.clientWidth / 2;\n      let nearest = 0;\n      let distance = Number.POSITIVE_INFINITY;\n      slideRefs.current.forEach((slide, index) => {\n        if (!slide) return;\n        const nextDistance = Math.abs(slide.offsetLeft + slide.offsetWidth / 2 - center);\n        if (nextDistance < distance) {\n          distance = nextDistance;\n          nearest = index;\n        }\n      });\n      selectIndex(nearest);\n    });\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const keyTargets: Record<string, number> = {\n      ArrowLeft: activeIndex - 1,\n      ArrowRight: activeIndex + 1,\n      Home: 0,\n      End: items.length - 1,\n    };\n    const next = keyTargets[event.key];\n    if (next === undefined) return;\n    event.preventDefault();\n    goTo(next, false, true);\n  };\n\n  const hasPrevious = activeIndex > 0;\n  const hasNext = activeIndex < items.length - 1;\n  const arrowMotionClass = reduced\n    ? \"transition-[background-color,color] duration-150\"\n    : \"transition-[background-color,color,transform] duration-150 active:scale-[0.96] disabled:active:scale-100\";\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      className={`w-full overflow-hidden rounded-[10px] border border-neutral-200 bg-[#f5f5f5] p-3 dark:border-white/[0.14] dark:bg-[#181818] ${className}`}\n    >\n      <header className=\"mb-2 flex min-h-11 items-center justify-between gap-3 px-1\">\n        <div className=\"min-w-0\">\n          <span className=\"block font-mono text-[9px] text-neutral-500 dark:text-neutral-400\">\n            {copy.collection}\n          </span>\n          <h3\n            id={titleId}\n            className=\"mt-0.5 truncate text-[13px] font-medium tracking-[-0.015em] text-[#292929] dark:text-neutral-100\"\n          >\n            {label}\n          </h3>\n        </div>\n        <div className=\"flex items-center gap-1.5\">\n          <span\n            aria-live=\"polite\"\n            className=\"mr-1 font-mono text-[10px] tabular-nums text-neutral-600 dark:text-neutral-300\"\n          >\n            {items.length === 0 ? \"00 / 00\" : `${String(activeIndex + 1).padStart(2, \"0\")} / ${String(items.length).padStart(2, \"0\")}`}\n          </span>\n          <button\n            type=\"button\"\n            aria-label={copy.previousSlide}\n            disabled={!hasPrevious}\n            onClick={() => goTo(activeIndex - 1, true)}\n            className={`grid size-11 place-items-center rounded-lg border border-black/[0.08] bg-white text-[#292929] outline-none disabled:cursor-default disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-2 dark:border-white/[0.12] dark:bg-[#202020] dark:text-neutral-100 dark:disabled:text-neutral-600 ${arrowMotionClass}`}\n          >\n            {PREVIOUS_ICON}\n          </button>\n          <button\n            type=\"button\"\n            aria-label={copy.nextSlide}\n            disabled={!hasNext}\n            onClick={() => goTo(activeIndex + 1, true)}\n            className={`grid size-11 place-items-center rounded-lg border border-black/[0.08] bg-white text-[#292929] outline-none disabled:cursor-default disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-2 dark:border-white/[0.12] dark:bg-[#202020] dark:text-neutral-100 dark:disabled:text-neutral-600 ${arrowMotionClass}`}\n          >\n            {NEXT_ICON}\n          </button>\n        </div>\n      </header>\n\n      <div\n        ref={viewportRef}\n        role=\"region\"\n        aria-roledescription={copy.carouselRole}\n        aria-label={label}\n        tabIndex={items.length === 0 ? -1 : 0}\n        onKeyDown={onKeyDown}\n        onScroll={updateFromScroll}\n        className=\"flex snap-x snap-mandatory gap-2.5 overflow-x-auto overscroll-x-contain rounded-[10px] outline-none [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-2 [&::-webkit-scrollbar]:hidden\"\n        style={{ touchAction: \"pan-x pan-y\" }}\n      >\n        {items.length === 0 ? (\n          <p role=\"status\" className=\"grid min-h-[180px] min-w-full place-items-center px-4 text-center text-[12px] text-neutral-600 dark:text-neutral-300\">\n            {copy.emptyCollection}\n          </p>\n        ) : null}\n        {items.map((item, index) => {\n          const active = index === activeIndex;\n          return (\n            <article\n              key={item.id}\n              role=\"group\"\n              aria-roledescription={copy.slideRole}\n              aria-label={copy.position(index + 1, items.length)}\n              className=\"w-[88%] min-w-[88%] snap-center first:snap-start last:snap-end sm:w-[72%] sm:min-w-[72%]\"\n            >\n              <button\n                ref={(node) => {\n                  slideRefs.current[index] = node;\n                }}\n                type=\"button\"\n                aria-current={active ? \"true\" : undefined}\n                onClick={() => goTo(index, true)}\n                onFocus={() => {\n                  if (index !== activeIndex) goTo(index, false);\n                }}\n                className={`group block w-full overflow-hidden rounded-[10px] border bg-white text-left outline-none transition-[border-color] duration-150 focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-inset dark:bg-[#181818] ${\n                  active\n                    ? \"border-neutral-950 dark:border-neutral-50\"\n                    : \"border-black/[0.07] dark:border-white/[0.1]\"\n                }`}\n              >\n                <span className=\"block aspect-[16/10] overflow-hidden border-b border-black/[0.07] bg-neutral-100 dark:border-white/[0.1] dark:bg-white/[0.04]\">\n                  {item.art}\n                </span>\n                <span className=\"grid min-h-[112px] grid-cols-[1fr_auto] gap-x-4 gap-y-2 p-3.5\">\n                  <span className=\"min-w-0\">\n                    {item.eyebrow ? (\n                      <span className=\"block font-mono text-[9px] text-neutral-500 dark:text-neutral-400\">\n                        {item.eyebrow}\n                      </span>\n                    ) : null}\n                    <span className=\"mt-1 block text-[14px] font-medium tracking-[-0.02em] text-[#292929] dark:text-neutral-100\">\n                      {item.title}\n                    </span>\n                    {item.description ? (\n                      <span className=\"mt-1.5 block max-w-[34rem] text-[11px] leading-[1.55] text-neutral-500 dark:text-neutral-300\">\n                        {item.description}\n                      </span>\n                    ) : null}\n                  </span>\n                  {item.meta ? (\n                    <span className=\"self-start whitespace-nowrap font-mono text-[9px] text-neutral-600 dark:text-neutral-300\">\n                      {item.meta}\n                    </span>\n                  ) : null}\n                </span>\n              </button>\n            </article>\n          );\n        })}\n      </div>\n      <p className=\"sr-only\">\n        {copy.instructions}\n      </p>\n    </section>\n  );\n}\n"
    }
  ]
}
