{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"snap-text","title":"Snap Text","author":"CoDesign-Spa27","description":"A spring-driven text interaction that reacts to pointer movement and settles cleanly.","dependencies":["motion"],"files":[{"path":"components/ui-components/snap-text.tsx","content":"\"use client\";\n\nimport type { MotionValue } from \"motion/react\";\nimport { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type SnapTextSpring = {\n  bounce?: number;\n  damping?: number;\n  mass?: number;\n  stiffness?: number;\n  visualDuration?: number;\n};\n\nexport type SnapTextEffects = {\n  imageOffsetPercent?: number;\n  imageScaleFalloff?: number;\n  maxIndentSteps?: number;\n  outlineWidth?: number;\n  rowMinOpacity?: number;\n  rowOpacityFalloff?: number;\n  rowMinScale?: number;\n  rowScaleFalloff?: number;\n  rowStretch?: number;\n};\n\nexport type SnapTextProps = {\n  className?: string;\n  colors?: readonly string[];\n  effects?: SnapTextEffects;\n  fontSize?: number | string;\n  imageClassName?: string;\n  imageFrameAspectRatio?: number;\n  imageFrameClassName?: string;\n  imageFrameRadius?: number;\n  imageFrameWidth?: number | string;\n  images?: readonly string[];\n  indent?: number;\n  inactiveColor?: string;\n  initialIndex?: number;\n  itemHeight?: number;\n  items?: readonly string[];\n  onIndexChange?: (index: number) => void;\n  prefix?: ReactNode;\n  showCounter?: boolean;\n  spring?: SnapTextSpring;\n};\n\ntype ResolvedSnapTextEffects = Required<SnapTextEffects>;\n\ntype SnapTextRowProps = {\n  color: string;\n  effects: ResolvedSnapTextEffects;\n  indent: number;\n  inactiveColor: string;\n  index: number;\n  item: string;\n  itemHeight: number;\n  progress: MotionValue<number>;\n};\n\ntype SnapTextImageProps = {\n  effects: ResolvedSnapTextEffects;\n  imageClassName?: string;\n  index: number;\n  progress: MotionValue<number>;\n  shouldReduceMotion: boolean;\n  src: string;\n};\n\nexport const DEFAULT_SNAP_TEXT_ITEMS = [\n  \"A signal appears.\",\n  \"The grid wakes up.\",\n  \"Color breaks free.\",\n  \"Gravity lets go.\",\n  \"The impossible forms.\",\n  \"Everything comes alive.\",\n];\n\nexport const DEFAULT_SNAP_TEXT_IMAGES = [\n  \"/images/snap-text/i1.jpeg\",\n  \"/images/snap-text/i2.jpeg\",\n  \"/images/snap-text/i3.jpeg\",\n  \"/images/snap-text/i4.jpeg\",\n  \"/images/snap-text/i5.jpeg\",\n  \"/images/snap-text/i6.jpeg\",\n] as const;\n\nexport const DEFAULT_SNAP_TEXT_COLORS = [\n  \"#8CD7C0\",\n  \"#FFD873\",\n  \"#FF5768\",\n  \"#6C89C5\",\n  \"#FF60A8\",\n  \"#F5B2D3\",\n] as const;\n\nconst DEFAULT_SPRING = {\n  damping: 30,\n  mass: 0.8,\n  stiffness: 280,\n};\n\nconst DEFAULT_EFFECTS = {\n  imageOffsetPercent: 24,\n  imageScaleFalloff: 0.04,\n  maxIndentSteps: 3,\n  outlineWidth: 1,\n  rowMinOpacity: 0.24,\n  rowOpacityFalloff: 0.68,\n  rowMinScale: 0.8,\n  rowScaleFalloff: 0.1,\n  rowStretch: 0.1,\n} satisfies ResolvedSnapTextEffects;\n\nfunction toCssSize(value: number | string) {\n  return typeof value === \"number\" ? `${value}px` : value;\n}\n\nfunction clampIndex(index: number, itemCount: number) {\n  return Math.min(Math.max(index, 0), Math.max(itemCount - 1, 0));\n}\n\nfunction SnapTextImage({\n  effects,\n  imageClassName,\n  index,\n  progress,\n  shouldReduceMotion,\n  src,\n}: SnapTextImageProps) {\n  const opacity = useTransform(progress, (latestIndex) =>\n    Math.max(0, 1 - Math.abs(index - latestIndex)),\n  );\n  const transform = useTransform(progress, (latestIndex) => {\n    if (shouldReduceMotion) {\n      return \"translate3d(0px, 0%, 0px) scale3d(1, 1, 1)\";\n    }\n\n    const signedDistance = index - latestIndex;\n    const clampedDistance = Math.max(-1, Math.min(1, signedDistance));\n    const scale = 1 - Math.min(Math.abs(signedDistance), 1) * effects.imageScaleFalloff;\n\n    return `translate3d(0px, ${clampedDistance * effects.imageOffsetPercent}%, 0px) scale3d(${scale}, ${scale}, 1)`;\n  });\n\n  return (\n    <motion.div\n      className=\"absolute inset-0 origin-center will-change-[transform,opacity] [backface-visibility:hidden]\"\n      style={{ opacity, transform }}\n    >\n      {/* biome-ignore lint/performance/noImgElement: keeps registry consumers free to use arbitrary local or remote image sources */}\n      <img\n        alt=\"\"\n        className={cn(\"size-full select-none object-cover\", imageClassName)}\n        decoding=\"async\"\n        draggable={false}\n        loading=\"eager\"\n        src={src}\n      />\n    </motion.div>\n  );\n}\n\nfunction SnapTextRow({\n  color,\n  effects,\n  indent,\n  inactiveColor,\n  index,\n  item,\n  itemHeight,\n  progress,\n}: SnapTextRowProps) {\n  const opacity = useTransform(progress, (latestIndex) => {\n    const distance = Math.abs(index - latestIndex);\n    return Math.max(effects.rowMinOpacity, 1 - distance * effects.rowOpacityFalloff);\n  });\n  const fillOpacity = useTransform(progress, (latestIndex) =>\n    Math.max(0, 1 - Math.abs(index - latestIndex)),\n  );\n  const outlineOpacity = useTransform(fillOpacity, (latestOpacity) => 1 - latestOpacity);\n  const transform = useTransform(progress, (latestIndex) => {\n    const distance = Math.abs(index - latestIndex);\n    const horizontalOffset = Math.min(distance, effects.maxIndentSteps) * indent;\n    const scale = Math.max(effects.rowMinScale, 1 - distance * effects.rowScaleFalloff);\n    const stretch = 1 + Math.sin(Math.min(distance, 1) * Math.PI) * effects.rowStretch;\n\n    return `translate3d(${horizontalOffset}px, 0px, 0px) scale3d(${scale * stretch}, ${scale}, 1)`;\n  });\n\n  return (\n    <motion.li\n      aria-label={item}\n      className=\"relative flex w-max max-w-full origin-left items-center whitespace-nowrap font-semibold leading-none tracking-[-0.04em] will-change-transform\"\n      style={{ height: itemHeight, opacity, transform }}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"text-transparent\"\n        style={{\n          opacity: outlineOpacity,\n          WebkitTextStroke: `${effects.outlineWidth}px ${inactiveColor}`,\n        }}\n      >\n        {item}\n      </motion.span>\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 flex items-center\"\n        style={{ color, opacity: fillOpacity }}\n      >\n        {item}\n      </motion.span>\n    </motion.li>\n  );\n}\n\nexport function SnapText({\n  className,\n  colors = DEFAULT_SNAP_TEXT_COLORS,\n  effects,\n  fontSize = \"clamp(1.75rem, 4vw, 4.5rem)\",\n  imageClassName,\n  imageFrameAspectRatio = 0.5,\n  imageFrameClassName,\n  imageFrameRadius = 12,\n  imageFrameWidth = \"clamp(4.5rem, 10vw, 19rem)\",\n  images = DEFAULT_SNAP_TEXT_IMAGES,\n  indent = 32,\n  inactiveColor = \"#737373\",\n  initialIndex = 3,\n  itemHeight = 104,\n  items = DEFAULT_SNAP_TEXT_ITEMS,\n  onIndexChange,\n  prefix,\n  showCounter = true,\n  spring,\n}: SnapTextProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const sectionHeightRef = useRef(0);\n  const activeIndexRef = useRef(clampIndex(initialIndex, items.length));\n  const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);\n  const scrollIndex = useMotionValue(activeIndexRef.current);\n  const viewportHeight = useMotionValue(0);\n  const visualItemHeight = useMotionValue(itemHeight);\n  const safeColors = colors.length > 0 ? colors : DEFAULT_SNAP_TEXT_COLORS;\n  const resolvedEffects = { ...DEFAULT_EFFECTS, ...effects };\n  const springConfig = useMemo(() => {\n    if (spring?.visualDuration !== undefined || spring?.bounce !== undefined) {\n      return {\n        bounce: spring.bounce ?? 0,\n        visualDuration: spring.visualDuration ?? 0.35,\n      };\n    }\n\n    return {\n      damping: spring?.damping ?? DEFAULT_SPRING.damping,\n      mass: spring?.mass ?? DEFAULT_SPRING.mass,\n      stiffness: spring?.stiffness ?? DEFAULT_SPRING.stiffness,\n    };\n  }, [spring?.bounce, spring?.damping, spring?.mass, spring?.stiffness, spring?.visualDuration]);\n  const presentedIndex = useSpring(scrollIndex, springConfig);\n  const trackTransform = useTransform(\n    [presentedIndex, viewportHeight, visualItemHeight],\n    ([latestIndex, latestHeight, latestItemHeight]: number[]) =>\n      `translate3d(0px, ${latestHeight / 2 - latestItemHeight / 2 - latestIndex * latestItemHeight}px, 0px)`,\n  );\n\n  const commitIndex = useCallback(\n    (nextIndex: number) => {\n      const clampedIndex = clampIndex(nextIndex, items.length);\n\n      if (clampedIndex === activeIndexRef.current) {\n        return;\n      }\n\n      activeIndexRef.current = clampedIndex;\n      setActiveIndex(clampedIndex);\n      onIndexChange?.(clampedIndex);\n    },\n    [items.length, onIndexChange],\n  );\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const scrollDriver = scrollRef.current;\n\n    if (!root || !scrollDriver) {\n      return;\n    }\n\n    const syncSize = () => {\n      const nextHeight = root.getBoundingClientRect().height;\n\n      if (nextHeight <= 0) {\n        return;\n      }\n\n      const previousHeight = sectionHeightRef.current;\n      const currentProgress =\n        previousHeight > 0 ? scrollDriver.scrollTop / previousHeight : activeIndexRef.current;\n\n      sectionHeightRef.current = nextHeight;\n      viewportHeight.set(nextHeight);\n      visualItemHeight.set(itemHeight);\n      scrollDriver.scrollTop = currentProgress * nextHeight;\n      scrollIndex.jump(currentProgress);\n      presentedIndex.jump(currentProgress);\n    };\n\n    syncSize();\n    const resizeObserver = new ResizeObserver(syncSize);\n    resizeObserver.observe(root);\n\n    return () => resizeObserver.disconnect();\n  }, [itemHeight, presentedIndex, scrollIndex, viewportHeight, visualItemHeight]);\n\n  useEffect(() => {\n    const scrollDriver = scrollRef.current;\n    const clampedIndex = clampIndex(initialIndex, items.length);\n\n    activeIndexRef.current = clampedIndex;\n    setActiveIndex(clampedIndex);\n    scrollIndex.jump(clampedIndex);\n    presentedIndex.jump(clampedIndex);\n\n    if (scrollDriver && sectionHeightRef.current > 0) {\n      scrollDriver.scrollTop = clampedIndex * sectionHeightRef.current;\n    }\n  }, [initialIndex, items.length, presentedIndex, scrollIndex]);\n\n  useEffect(() => {\n    if (shouldReduceMotion) {\n      presentedIndex.jump(scrollIndex.get());\n    }\n  }, [presentedIndex, scrollIndex, shouldReduceMotion]);\n\n  useEffect(() => {\n    const scrollDriver = scrollRef.current;\n\n    if (!scrollDriver || items.length === 0) {\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        let closestEntry: IntersectionObserverEntry | null = null;\n\n        for (const entry of entries) {\n          if (\n            entry.isIntersecting &&\n            entry.intersectionRatio >= 0.55 &&\n            (!closestEntry || entry.intersectionRatio > closestEntry.intersectionRatio)\n          ) {\n            closestEntry = entry;\n          }\n        }\n\n        if (!closestEntry) {\n          return;\n        }\n\n        const nextIndex = Number((closestEntry.target as HTMLElement).dataset.snapIndex);\n\n        if (Number.isInteger(nextIndex)) {\n          commitIndex(nextIndex);\n        }\n      },\n      { root: scrollDriver, threshold: [0.55, 0.75, 1] },\n    );\n\n    for (const section of scrollDriver.querySelectorAll(\"[data-snap-index]\")) {\n      observer.observe(section);\n    }\n\n    return () => observer.disconnect();\n  }, [commitIndex, items.length]);\n\n  if (items.length === 0) {\n    return null;\n  }\n\n  const hasImagePrefix = images.length > 0;\n  const hasPrefix = prefix !== undefined && prefix !== null && prefix !== false && prefix !== \"\";\n  const prefixText =\n    hasPrefix && (typeof prefix === \"string\" || typeof prefix === \"number\") ? `${prefix} ` : \"\";\n  const activeColor = safeColors[activeIndex % safeColors.length] ?? DEFAULT_SNAP_TEXT_COLORS[0];\n  const sequenceNumber = String(activeIndex + 1).padStart(2, \"0\");\n  const sequenceTotal = String(items.length).padStart(2, \"0\");\n\n  const selectWithKeyboard = (nextIndex: number) => {\n    const scrollDriver = scrollRef.current;\n    const clampedIndex = clampIndex(nextIndex, items.length);\n\n    if (!scrollDriver || clampedIndex === activeIndexRef.current) {\n      return false;\n    }\n\n    scrollDriver.scrollTop = clampedIndex * sectionHeightRef.current;\n    scrollIndex.jump(clampedIndex);\n    presentedIndex.jump(clampedIndex);\n    commitIndex(clampedIndex);\n    return true;\n  };\n\n  return (\n    <div\n      className={cn(\n        \"relative h-svh min-h-[520px] w-full overflow-hidden font-sans text-white\",\n        className,\n      )}\n      ref={rootRef}\n      style={{ fontSize: toCssSize(fontSize) }}\n    >\n      {showCounter ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-6 right-[7%] z-20 font-mono text-[11px] tracking-[0.16em]\"\n        >\n          <span style={{ color: activeColor }}>{sequenceNumber}</span>\n          <span className=\"text-muted-foreground\"> / {sequenceTotal}</span>\n        </div>\n      ) : null}\n\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute inset-x-[7%] inset-y-0 flex\",\n          (hasImagePrefix || hasPrefix) && \"gap-[clamp(0.75rem,2vw,2rem)]\",\n        )}\n      >\n        {hasImagePrefix ? (\n          <div\n            className={cn(\n              \"relative shrink-0 self-center overflow-hidden bg-neutral-950\",\n              imageFrameClassName,\n            )}\n            style={{\n              aspectRatio: imageFrameAspectRatio,\n              borderRadius: imageFrameRadius,\n              width: toCssSize(imageFrameWidth),\n            }}\n          >\n            {items.map((item, index) => (\n              <SnapTextImage\n                effects={resolvedEffects}\n                imageClassName={imageClassName}\n                index={index}\n                key={`${item}-${images[index % images.length]}`}\n                progress={presentedIndex}\n                shouldReduceMotion={Boolean(shouldReduceMotion)}\n                src={images[index % images.length] ?? DEFAULT_SNAP_TEXT_IMAGES[0]}\n              />\n            ))}\n          </div>\n        ) : null}\n\n        {hasPrefix ? (\n          <span className=\"flex shrink-0 items-center font-semibold leading-none tracking-[-0.04em] text-foreground\">\n            {prefix}\n          </span>\n        ) : null}\n\n        <div className=\"relative min-w-0 flex-1\">\n          <motion.ul\n            className=\"absolute inset-x-0 top-0 m-0 list-none p-0\"\n            style={{ transform: trackTransform }}\n          >\n            {items.map((item, index) => (\n              <SnapTextRow\n                color={safeColors[index % safeColors.length] ?? DEFAULT_SNAP_TEXT_COLORS[0]}\n                effects={resolvedEffects}\n                indent={indent}\n                inactiveColor={inactiveColor}\n                index={index}\n                item={item}\n                itemHeight={itemHeight}\n                key={item}\n                progress={presentedIndex}\n              />\n            ))}\n          </motion.ul>\n        </div>\n      </div>\n\n      <div\n        aria-label=\"Scroll-controlled text sequence\"\n        aria-orientation=\"vertical\"\n        aria-valuemax={items.length - 1}\n        aria-valuemin={0}\n        aria-valuenow={activeIndex}\n        aria-valuetext={`${prefixText}${items[activeIndex]}`}\n        className=\"absolute inset-0 z-10 flex snap-y snap-mandatory flex-col overflow-y-scroll overscroll-y-contain outline-none [scrollbar-width:none] [-ms-overflow-style:none] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70 [&::-webkit-scrollbar]:hidden\"\n        onKeyDown={(event) => {\n          let nextIndex: number | null = null;\n\n          if (event.key === \"ArrowDown\" || event.key === \"PageDown\") {\n            nextIndex = activeIndexRef.current + 1;\n          } else if (event.key === \"ArrowUp\" || event.key === \"PageUp\") {\n            nextIndex = activeIndexRef.current - 1;\n          } else if (event.key === \"Home\") {\n            nextIndex = 0;\n          } else if (event.key === \"End\") {\n            nextIndex = items.length - 1;\n          }\n\n          if (nextIndex !== null && selectWithKeyboard(nextIndex)) {\n            event.preventDefault();\n          }\n        }}\n        onScroll={(event) => {\n          const sectionHeight = sectionHeightRef.current;\n\n          if (sectionHeight <= 0) {\n            return;\n          }\n\n          const nextProgress = event.currentTarget.scrollTop / sectionHeight;\n          scrollIndex.set(nextProgress);\n\n          if (shouldReduceMotion) {\n            presentedIndex.jump(nextProgress);\n          }\n        }}\n        ref={scrollRef}\n        role=\"slider\"\n        tabIndex={0}\n      >\n        {items.map((item, index) => (\n          <div\n            aria-hidden=\"true\"\n            className=\"h-full flex-none snap-start snap-always\"\n            data-snap-index={index}\n            key={item}\n          />\n        ))}\n      </div>\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {prefixText}\n        {items[activeIndex]}, item {activeIndex + 1} of {items.length}\n      </p>\n    </div>\n  );\n}\n","type":"registry:ui"}],"categories":["text","interaction"],"type":"registry:ui"}