{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A KineticTypeScroll text animation component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type MotionValue,\n  motion,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport type { RefObject } from \"react\";\nimport { useRef } from \"react\";\n\nexport type KineticTypeScrollAlign = \"start\" | \"center\" | \"end\";\n\ntype UseScrollOptions = Parameters<typeof useScroll>[0];\nexport type ScrollOffsetTuple = NonNullable<UseScrollOptions>[\"offset\"];\n\nexport interface KineticTypeScrollProps {\n  align?: KineticTypeScrollAlign;\n  className?: string;\n  /** Scrollable ancestor whose progress drives the effect. */\n  container?: RefObject<HTMLElement | null>;\n  /** Opacity at rest vs. at each word's peak. */\n  dimRange?: [number, number];\n  /** Vertical offset in px at rest vs. at each word's peak. */\n  liftRange?: [number, number];\n  /** Motion scroll offset tuple, forwarded to `useScroll`. */\n  offset?: ScrollOffsetTuple;\n  /** Scale applied at rest vs. at each word's peak. */\n  scaleRange?: [number, number];\n  /** Fractional offset between each word's active scroll window (0-1). */\n  stagger?: number;\n  /** Letter-spacing in px at rest vs. at each word's peak. */\n  trackingRange?: [number, number];\n  /**\n   * `font-variation-settings` \"wght\" at rest vs. at each word's peak.\n   * Only visible when the active font is a variable font.\n   */\n  weightRange?: [number, number];\n  words: string[];\n}\n\ninterface KineticWordProps {\n  dimRange: [number, number];\n  index: number;\n  liftRange: [number, number];\n  progress: MotionValue<number>;\n  scaleRange: [number, number];\n  shouldReduceMotion: boolean;\n  stagger: number;\n  total: number;\n  trackingRange: [number, number];\n  weightRange: [number, number];\n  word: string;\n}\n\nconst ALIGN_ROW_CLASS: Record<KineticTypeScrollAlign, string> = {\n  center: \"justify-center\",\n  end: \"justify-end\",\n  start: \"justify-start\",\n};\n\nconst DEFAULT_SCALE_RANGE: [number, number] = [0.92, 1.18];\nconst DEFAULT_TRACKING_RANGE: [number, number] = [-0.5, 2.5];\nconst DEFAULT_WEIGHT_RANGE: [number, number] = [380, 800];\nconst DEFAULT_LIFT_RANGE: [number, number] = [0, -12];\n// Never fully dark: the phrase stays readable at rest and the crest is what\n// lights it, so the wave is legible without the line ever going missing.\nconst DEFAULT_DIM_RANGE: [number, number] = [0.45, 1];\nconst DEFAULT_STAGGER = 0.12;\n/**\n * Head- and tail-room at both ends of the scroll range: the wave starts after\n * the phrase has settled into view and ends before it leaves, so there is time\n * to read the line on either side of the effect.\n */\nconst WAVE_MARGIN = 0.14;\n/**\n * Each word's active window as a multiple of the gap between peaks. Above 1 the\n * windows overlap, which is what turns a row of individual pulses into a single\n * wave travelling through the phrase.\n */\nconst WINDOW_OVERLAP = 1.2;\nconst MIN_WINDOW = 0.09;\nconst INTENSITY_MIDPOINT = 1;\nconst INTENSITY_SCALE = 2;\n// A soft, near-critically-damped spring: the wave should read as one moving\n// crest, not as each word snapping.\nconst SPRING_TRANSITION = {\n  bounce: 0,\n  duration: 0.32,\n  type: \"spring\" as const,\n};\n\n/** Motion default (`[\"start start\", \"end end\"]`) collapses on an element\n * shorter than its scroller, which left the whole effect stuck near 0. This\n * window opens as the phrase enters the lower part of the viewport and closes\n * around the time it reaches the middle. */\nconst DEFAULT_OFFSET = [\"start 0.9\", \"end 0.45\"] as ScrollOffsetTuple;\n\nconst KineticWord = ({\n  word,\n  dimRange,\n  index,\n  liftRange,\n  progress,\n  scaleRange,\n  shouldReduceMotion,\n  stagger,\n  total,\n  trackingRange,\n  weightRange,\n}: KineticWordProps) => {\n  const gaps = Math.max(1, total - 1);\n  // Peaks are evenly spaced and the whole wave is centred in the range, so a\n  // short phrase does not crowd into the first third of the scroll.\n  const spacing = Math.min(stagger, (1 - 2 * WAVE_MARGIN) / gaps);\n  const waveStart = (1 - spacing * gaps) / 2;\n  const center = waveStart + spacing * index;\n  const halfWindow = Math.max(spacing * WINDOW_OVERLAP, MIN_WINDOW);\n  const start = center - halfWindow;\n  const end = center + halfWindow;\n\n  const localProgress = useTransform(progress, [start, end], [0, 1], {\n    clamp: true,\n  });\n  const intensity = useTransform(\n    localProgress,\n    (value) => INTENSITY_MIDPOINT - Math.abs(value * INTENSITY_SCALE - 1)\n  );\n  const rawScale = useTransform(intensity, [0, 1], scaleRange);\n  const rawTracking = useTransform(intensity, [0, 1], trackingRange);\n  const rawWeight = useTransform(intensity, [0, 1], weightRange);\n  const rawLift = useTransform(intensity, [0, 1], liftRange);\n  const rawOpacity = useTransform(intensity, [0, 1], dimRange);\n  const scale = useSpring(rawScale, SPRING_TRANSITION);\n  const trackingValue = useSpring(rawTracking, SPRING_TRANSITION);\n  const weight = useSpring(rawWeight, SPRING_TRANSITION);\n  const y = useSpring(rawLift, SPRING_TRANSITION);\n  const opacity = useSpring(rawOpacity, SPRING_TRANSITION);\n  const letterSpacing = useTransform(trackingValue, (value) => `${value}px`);\n  const fontVariationSettings = useTransform(\n    weight,\n    (value) => `\"wght\" ${Math.round(value)}`\n  );\n\n  if (shouldReduceMotion) {\n    // No travel and no dimming — the phrase simply reads at its peak weight.\n    return (\n      <span\n        aria-hidden=\"true\"\n        className=\"inline-block\"\n        style={{ fontVariationSettings: `\"wght\" ${weightRange[1]}` }}\n      >\n        {word}\n      </span>\n    );\n  }\n\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      className=\"inline-block will-change-transform\"\n      style={{ fontVariationSettings, letterSpacing, opacity, scale, y }}\n    >\n      {word}\n    </motion.span>\n  );\n};\n\n/**\n * KineticTypeScroll — editorial kinetic typography driven by scroll.\n * Each word maps a slice of scroll progress to its own scale, tracking\n * and weight curve so the phrase breathes as the section scrolls.\n */\nexport default function KineticTypeScroll({\n  words,\n  align = \"center\",\n  className,\n  container,\n  dimRange = DEFAULT_DIM_RANGE,\n  liftRange = DEFAULT_LIFT_RANGE,\n  offset = DEFAULT_OFFSET,\n  scaleRange = DEFAULT_SCALE_RANGE,\n  stagger = DEFAULT_STAGGER,\n  trackingRange = DEFAULT_TRACKING_RANGE,\n  weightRange = DEFAULT_WEIGHT_RANGE,\n}: KineticTypeScrollProps) {\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n  const { scrollYProgress } = useScroll({\n    container,\n    offset,\n    target: wrapperRef,\n  });\n\n  return (\n    <div\n      className={cn(\n        // Gaps are in `em` and sized to clear the peak scale: `scale` is a\n        // transform, so a magnified word overflows its layout box and a fixed\n        // 8px gutter lets the crest collide with its neighbours.\n        \"flex flex-wrap gap-x-[0.4em] gap-y-[0.18em]\",\n        ALIGN_ROW_CLASS[align],\n        className\n      )}\n      ref={wrapperRef}\n    >\n      {/* Every glyph below is aria-hidden, so the readable copy lives here. An\n          aria-label on this plain div would be dropped by assistive tech. */}\n      <span className=\"sr-only\">{words.join(\" \")}</span>\n      {words.map((word, index) => (\n        <KineticWord\n          dimRange={dimRange}\n          index={index}\n          // biome-ignore lint/suspicious/noArrayIndexKey: words may repeat and have no stable id\n          key={index}\n          liftRange={liftRange}\n          progress={scrollYProgress}\n          scaleRange={scaleRange}\n          shouldReduceMotion={!!shouldReduceMotion}\n          stagger={stagger}\n          total={words.length}\n          trackingRange={trackingRange}\n          weightRange={weightRange}\n          word={word}\n        />\n      ))}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/kinetic-type-scroll/index.tsx","type":"registry:ui"}],"name":"kinetic-type-scroll","registryDependencies":[],"title":"Kinetic Type Scroll","type":"registry:ui"}