{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-horizontal-scroll",
  "title": "Horizontal scroll",
  "description": "A pinned row of cards that travels sideways as the page scrolls down.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/horizontal-scroll.tsx",
      "content": "'use client';\nimport {\n  motion,\n  type MotionValue,\n  useMotionValue,\n  useScroll,\n  useTransform,\n} from 'motion/react';\nimport {\n  type FocusEvent,\n  type ReactNode,\n  type RefObject,\n  useEffect,\n  useRef,\n} from 'react';\nimport { cn } from '@/lib/utils';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\n\nconst FOCUS_INSET = 24;\n\nexport type HorizontalScrollProps = {\n  children: ReactNode;\n  className?: string;\n  frameClassName?: string;\n  trackClassName?: string;\n  containerRef?: RefObject<HTMLElement | null>;\n  showProgress?: boolean;\n  'aria-label'?: string;\n};\n\nfunction trackOffsetOf(\n  prefersReducedMotion: boolean | null,\n  directedTravel: number\n) {\n  if (prefersReducedMotion) {\n    return 0;\n  }\n  return -directedTravel;\n}\n\nfunction progressOf(\n  prefersReducedMotion: boolean | null,\n  rowProgress: MotionValue<number>,\n  pinnedProgress: MotionValue<number>\n) {\n  if (prefersReducedMotion) {\n    return rowProgress;\n  }\n  return pinnedProgress;\n}\n\nfunction directionSignOf(element: HTMLElement) {\n  const { direction } = getComputedStyle(element);\n  const isRightToLeft = direction === 'rtl';\n  if (isRightToLeft) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction startGapOf(cardRect: DOMRect, viewportRect: DOMRect, sign: number) {\n  if (sign < 0) {\n    return viewportRect.right - cardRect.right;\n  }\n  return cardRect.left - viewportRect.left;\n}\n\nfunction scrollerOf(containerRef?: RefObject<HTMLElement | null>) {\n  const container = containerRef?.current;\n  if (container) {\n    return container;\n  }\n  return window;\n}\n\nfunction viewportHeightOf(containerRef?: RefObject<HTMLElement | null>) {\n  const container = containerRef?.current;\n  if (container) {\n    return container.clientHeight;\n  }\n  return window.innerHeight;\n}\n\nfunction useTrackGeometry(\n  viewportRef: RefObject<HTMLDivElement | null>,\n  trackRef: RefObject<HTMLDivElement | null>\n) {\n  const distance = useMotionValue(0);\n  const direction = useMotionValue(1);\n\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    const track = trackRef.current;\n    if (!viewport || !track) {\n      return;\n    }\n\n    const measure = () => {\n      const overflow = track.offsetWidth - viewport.clientWidth;\n      const travel = Math.max(0, overflow);\n      const sign = directionSignOf(viewport);\n      distance.set(travel);\n      direction.set(sign);\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(viewport);\n    observer.observe(track);\n    return () => observer.disconnect();\n  }, [distance, direction, viewportRef, trackRef]);\n\n  return { distance, direction };\n}\n\nexport function HorizontalScroll({\n  children,\n  className,\n  frameClassName,\n  trackClassName,\n  containerRef,\n  showProgress = false,\n  'aria-label': ariaLabel,\n}: HorizontalScrollProps) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const { distance, direction } = useTrackGeometry(viewportRef, trackRef);\n\n  const { scrollYProgress } = useScroll({\n    target: sectionRef,\n    container: containerRef,\n    offset: ['start start', 'end end'],\n  });\n  const { scrollXProgress } = useScroll({ container: viewportRef });\n\n  const x = useTransform(() => {\n    const pinnedProgress = scrollYProgress.get();\n    const totalTravel = distance.get();\n    const sign = direction.get();\n    const travel = pinnedProgress * totalTravel;\n    const directedTravel = travel * sign;\n    return trackOffsetOf(prefersReducedMotion, directedTravel);\n  });\n  const progress = progressOf(\n    prefersReducedMotion,\n    scrollXProgress,\n    scrollYProgress\n  );\n\n  // The track is clipped, not scrolled, so the browser cannot bring a focused card into view\n  // by itself: the page scrolls by the distance that moves the card in.\n  const revealFocusedElement = (event: FocusEvent<HTMLDivElement>) => {\n    const section = sectionRef.current;\n    const viewport = viewportRef.current;\n    if (prefersReducedMotion) {\n      return;\n    }\n    if (!section || !viewport) {\n      return;\n    }\n\n    const cardRect = event.target.getBoundingClientRect();\n    const viewportRect = viewport.getBoundingClientRect();\n    const startsBefore = cardRect.left < viewportRect.left;\n    const endsAfter = cardRect.right > viewportRect.right;\n    const isFullyVisible = !startsBefore && !endsAfter;\n    const totalTravel = distance.get();\n    if (isFullyVisible || totalTravel === 0) {\n      return;\n    }\n\n    const currentOffset = x.get();\n    const currentTravel = Math.abs(currentOffset);\n    const sign = direction.get();\n    const startGap = startGapOf(cardRect, viewportRect, sign);\n    const shift = startGap - FOCUS_INSET;\n    const unclampedTravel = currentTravel + shift;\n    const travelFromStart = Math.max(0, unclampedTravel);\n    const wantedTravel = Math.min(totalTravel, travelFromStart);\n    const viewportHeight = viewportHeightOf(containerRef);\n    const scrollRange = section.offsetHeight - viewportHeight;\n    const pixelsPerTravel = scrollRange / totalTravel;\n    const travelDelta = wantedTravel - currentTravel;\n    const scrollDelta = travelDelta * pixelsPerTravel;\n    const scroller = scrollerOf(containerRef);\n    scroller.scrollBy({ top: scrollDelta, behavior: 'instant' });\n  };\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label={ariaLabel}\n      className={cn('relative', className)}\n    >\n      <div\n        className={cn(\n          'sticky top-0 flex h-svh flex-col justify-center gap-6',\n          'motion-reduce:static motion-reduce:h-auto motion-reduce:py-8',\n          frameClassName\n        )}\n      >\n        <div\n          ref={viewportRef}\n          onFocus={revealFocusedElement}\n          className='overflow-clip motion-reduce:overflow-x-auto'\n        >\n          <motion.div\n            ref={trackRef}\n            style={{ x }}\n            className={cn('flex w-max gap-4 px-6', trackClassName)}\n          >\n            {children}\n          </motion.div>\n        </div>\n        {showProgress && (\n          <div\n            aria-hidden='true'\n            className='bg-foreground/10 mx-6 h-0.5 overflow-clip rounded-full'\n          >\n            <motion.div\n              style={{ scaleX: progress }}\n              className='bg-primary h-full origin-left rtl:origin-right'\n            />\n          </div>\n        )}\n      </div>\n      <motion.div\n        aria-hidden='true'\n        style={{ height: distance }}\n        className='motion-reduce:hidden'\n      />\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/horizontal-scroll.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}