{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snap-carousel",
  "type": "registry:component",
  "title": "Snap Carousel",
  "description": "Snapping card carousel on native scrolling: CSS scroll-snap centers each card, a scroll-driven animation fans the neighbours out, arrows step one card, dots jump to one, mouse drag releases onto the closest card.",
  "author": "asmyshlyaev177 <https://asmyshlyaev177.dev>",
  "dependencies": [
    "react-horizontal-scrolling-menu",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "components/ui/snap-carousel.tsx",
      "target": "components/ui/snap-carousel.tsx",
      "type": "registry:component",
      "content": "'use client';\n\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport * as React from 'react';\nimport {\n  type publicApiType,\n  ScrollMenu,\n  VisibilityContext,\n} from 'react-horizontal-scrolling-menu';\n\nimport { Button } from '@/components/ui/button';\nimport { cn } from '@/lib/utils';\n\ntype SnapCarouselProps = Omit<\n  React.ComponentProps<typeof ScrollMenu>,\n  | 'LeftArrow'\n  | 'RightArrow'\n  | 'Footer'\n  | 'itemClassName'\n  | 'scrollContainerClassName'\n  | 'onScroll'\n  | 'onMouseDown'\n  | 'onMouseMove'\n  | 'onMouseUp'\n  | 'onMouseLeave'\n> & {\n  /** Width of every card, any CSS length; padding and fan derive from it. */\n  cardWidth?: string;\n  /** Space between cards, any CSS length. */\n  gap?: string;\n  /** Degrees each neighbour turns, per card away from the center. */\n  tilt?: number;\n  className?: string;\n};\n\n/**\n * Snapping card carousel: CSS scroll-snap centers each card and a CSS\n * scroll-driven animation fans the neighbours out (flat where the platform\n * lacks it — Firefox still flags it). Arrows step one card, dots jump to\n * one, a mouse drag releases onto the closest card; touch is native.\n * Every child needs a unique `itemId` prop and fills its slot.\n * Docs: https://react-horizontal-scrolling-menu.dev/testimonial-carousel\n */\nfunction SnapCarousel({\n  cardWidth = 'min(17.5rem, 78vw)',\n  gap = '1rem',\n  tilt = 8,\n  className,\n  children,\n  ...props\n}: SnapCarouselProps) {\n  const ids = React.Children.toArray(children).map(\n    (child) => (child as React.ReactElement<{ itemId: string }>).props.itemId,\n  );\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const drag = React.useRef({ position: 0, clicked: false });\n  const snapTimer = React.useRef(0);\n\n  // A mandatory snap container snaps every scrollLeft write, so snapping is\n  // off for the gesture and returns 150ms after the rail last moved:\n  // re-enabling it mid-glide jumps to the snap point instead of gliding.\n  const restoreSnapWhenSettled = (api: publicApiType) => {\n    window.clearTimeout(snapTimer.current);\n    snapTimer.current = window.setTimeout(() => {\n      const rail = api.scrollContainer.current;\n      if (rail && !drag.current.clicked) rail.style.scrollSnapType = '';\n    }, 150);\n  };\n  React.useEffect(() => () => window.clearTimeout(snapTimer.current), []);\n\n  // Released between cards: glide to the closest one. The timer is armed\n  // here too, so a release that needs no glide still restores snap.\n  const dragStop = (api: publicApiType) => () => {\n    if (!drag.current.clicked) return;\n    drag.current.clicked = false;\n    scrollToIndex(api, ids, nearestIndex(api, ids));\n    restoreSnapWhenSettled(api);\n  };\n\n  return (\n    <div\n      className={cn(\n        'relative max-w-full',\n        // The library's stylesheet as utilities — a CSS side-effect import\n        // is a TS error in fresh TypeScript 6 projects.\n        '[&_.react-horizontal-scrolling-menu--inner-wrapper]:relative',\n        '[&_.react-horizontal-scrolling-menu--inner-wrapper]:flex',\n        '[&_.react-horizontal-scrolling-menu--inner-wrapper]:overflow-y-hidden',\n        // Arrows float over the rail, so its 50% padding centers the edge\n        // cards exactly.\n        '[&_.react-horizontal-scrolling-menu--arrow-left]:absolute',\n        '[&_.react-horizontal-scrolling-menu--arrow-left]:top-1/2',\n        '[&_.react-horizontal-scrolling-menu--arrow-left]:left-1',\n        '[&_.react-horizontal-scrolling-menu--arrow-left]:z-10',\n        '[&_.react-horizontal-scrolling-menu--arrow-left]:-translate-y-1/2',\n        '[&_.react-horizontal-scrolling-menu--arrow-right]:absolute',\n        '[&_.react-horizontal-scrolling-menu--arrow-right]:top-1/2',\n        '[&_.react-horizontal-scrolling-menu--arrow-right]:right-1',\n        '[&_.react-horizontal-scrolling-menu--arrow-right]:z-10',\n        '[&_.react-horizontal-scrolling-menu--arrow-right]:-translate-y-1/2',\n        className,\n      )}\n      style={\n        {\n          '--card': cardWidth,\n          '--gap': gap,\n          '--tilt': `${tilt}deg`,\n          '--pitch': `calc(${cardWidth} + ${gap})`,\n        } as React.CSSProperties\n      }\n    >\n      {/* React hoists and dedupes a keyed <style>, so many carousels share\n          one keyframes rule. */}\n      <style href=\"snap-carousel-tilt\" precedence=\"default\">\n        {TILT_KEYFRAMES}\n      </style>\n      <ScrollMenu\n        LeftArrow={\n          <SnapCarouselArrow\n            ids={ids}\n            target={activeIndex - 1}\n            direction=\"left\"\n          />\n        }\n        RightArrow={\n          <SnapCarouselArrow\n            ids={ids}\n            target={activeIndex + 1}\n            direction=\"right\"\n          />\n        }\n        Footer={<SnapCarouselDots ids={ids} activeIndex={activeIndex} />}\n        wrapperClassName=\"flex flex-col\"\n        scrollContainerClassName={cn(\n          'relative flex w-full overflow-y-hidden snap-x snap-mandatory',\n          // Side padding equal to the space beside a centered card lets the\n          // first and last card reach the middle; the bottom room is for the fan.\n          '[gap:var(--gap)] [padding-inline:calc(50%_-_var(--card)_/_2)] pt-4 pb-10',\n          'cursor-grab select-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',\n        )}\n        // The slot is the animated subject and your card fills it, so the\n        // fan needs no styles of its own. Its position in the rail is the\n        // timeline: flat in the middle, one --tilt further per neighbour.\n        itemClassName={cn(\n          'shrink-0 snap-center [width:var(--card)] origin-bottom [&>*]:h-full [&>*]:w-full',\n          'supports-[animation-timeline:view()]:[animation:snap-tilt_linear_both]',\n          'supports-[animation-timeline:view()]:[animation-timeline:view(inline)]',\n          'supports-[animation-timeline:view()]:[animation-range:cover_calc(50%_-_2_*_var(--pitch))_cover_calc(50%_+_2_*_var(--pitch))]',\n          'motion-reduce:[animation:none]',\n        )}\n        onScroll={(api) => {\n          setActiveIndex(nearestIndex(api, ids));\n          restoreSnapWhenSettled(api);\n        }}\n        onMouseDown={(api) => (ev) => {\n          const rail = api.scrollContainer.current;\n          if (rail) rail.style.scrollSnapType = 'none';\n          drag.current = { position: ev.clientX, clicked: true };\n        }}\n        onMouseMove={(api) => (ev) => {\n          const rail = api.scrollContainer.current;\n          const delta = drag.current.position - ev.clientX;\n          // 5px of slack keeps a plain click from nudging the rail.\n          if (!drag.current.clicked || !rail || Math.abs(delta) <= 5) return;\n          drag.current.position = ev.clientX;\n          rail.scrollLeft += delta;\n        }}\n        onMouseUp={dragStop}\n        onMouseLeave={dragStop}\n        {...props}\n      >\n        {children}\n      </ScrollMenu>\n    </div>\n  );\n}\n\nconst TILT_KEYFRAMES =\n  '@keyframes snap-tilt{from{transform:translateY(1.5rem) rotate(calc(2 * var(--tilt))) scale(.88)}50%{transform:none}to{transform:translateY(1.5rem) rotate(calc(-2 * var(--tilt))) scale(.88)}}';\n\n/** Index of the card whose center is closest to the rail's center. */\nfunction nearestIndex(api: publicApiType, ids: string[]) {\n  const rail = api.scrollContainer.current;\n  if (!rail) return 0;\n  const middle = rail.scrollLeft + rail.clientWidth / 2;\n  const distances = ids.map((id) => {\n    const slot = api.getItemElementById(id) as HTMLElement | null;\n    return slot\n      ? Math.abs(slot.offsetLeft + slot.offsetWidth / 2 - middle)\n      : Infinity;\n  });\n  return distances.indexOf(Math.min(...distances));\n}\n\nfunction scrollToIndex(api: publicApiType, ids: string[], index: number) {\n  const slot =\n    ids[index] === undefined ? null : api.getItemElementById(ids[index]);\n  if (slot) api.scrollToItem(slot, 'smooth', 'center');\n}\n\n/** Steps one card, not one page; disabled when the neighbour does not exist. */\nfunction SnapCarouselArrow({\n  ids,\n  target,\n  direction,\n}: {\n  ids: string[];\n  target: number;\n  direction: 'left' | 'right';\n}) {\n  const api = React.useContext<publicApiType>(VisibilityContext);\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      size=\"icon\"\n      className=\"rounded-full disabled:opacity-30\"\n      aria-label={direction === 'left' ? 'Previous card' : 'Next card'}\n      disabled={ids[target] === undefined}\n      onClick={() => scrollToIndex(api, ids, target)}\n    >\n      {direction === 'left' ? <ChevronLeft /> : <ChevronRight />}\n    </Button>\n  );\n}\n\n/** 24px targets (WCAG 2.5.8) drawing 10px dots; aria-current marks the centered card. */\nfunction SnapCarouselDots({\n  ids,\n  activeIndex,\n}: {\n  ids: string[];\n  activeIndex: number;\n}) {\n  const api = React.useContext<publicApiType>(VisibilityContext);\n  return (\n    <div role=\"group\" aria-label=\"Cards\" className=\"flex justify-center pt-2\">\n      {ids.map((id, index) => (\n        <button\n          type=\"button\"\n          key={id}\n          aria-label={`Card ${index + 1} of ${ids.length}`}\n          aria-current={index === activeIndex}\n          className={cn(\n            'grid size-6 cursor-pointer place-items-center',\n            'before:size-2.5 before:rounded-full before:bg-muted-foreground/40 before:content-[\"\"]',\n            'aria-[current=true]:before:bg-primary',\n          )}\n          onClick={() => scrollToIndex(api, ids, index)}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport { SnapCarousel };\n"
    }
  ],
  "docs": "Each card needs a unique itemId prop and fills its slot. Examples: https://react-horizontal-scrolling-menu.dev/testimonial-carousel"
}
