{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-text",
  "title": "Elastic Text",
  "description": "Variable-font text whose weight springs up under an automatic spotlight or the pointer.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/elastic-text/elastic-text.tsx",
      "content": "\"use client\";\n\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"framer-motion\";\nimport * as React from \"react\";\nimport { clamp, getTextContent, lerp } from \"../lib/text-utils\";\n\n/**\n * How the weight emphasis is driven:\n * - `auto`  — a spotlight sweeps across the text on its own (default).\n * - `hover` — the emphasis follows the pointer while it's over the text.\n */\nexport type ElasticTextMode = \"auto\" | \"hover\";\n\nexport type ElasticTextProps = React.HTMLAttributes<HTMLSpanElement> & {\n  children: React.ReactNode;\n  mode?: ElasticTextMode;\n  /** Resting (lightest) font weight. */\n  minWeight?: number;\n  /** Peak (heaviest) font weight under the spotlight / pointer. */\n  maxWeight?: number;\n  /** Seconds for one full `auto` sweep across the text. */\n  duration?: number;\n  /** Repeat the `auto` sweep. */\n  loop?: boolean;\n  /** Start the `auto` sweep only once the text scrolls into view. */\n  startOnView?: boolean;\n  /** Pointer influence radius in px (`hover` mode). */\n  radius?: number;\n};\n\nconst SPRING = { stiffness: 150, damping: 18, mass: 1 } as const;\nconst AUTO_SPREAD = 2.5;\nconst VIEW_THRESHOLD = 0.3;\n\n// Uses the theme sans font (Geist is a variable font with a `wght` axis); on a\n// non-variable font the weight steps to the nearest available cut.\nconst CONTAINER_CLASS =\n  \"inline-block font-sans leading-[1.1] text-inherit [font-optical-sizing:auto] [font-variation-settings:'wght'_400]\";\nconst SEGMENT_CLASS =\n  \"inline-block whitespace-pre [font-variation-settings:'wght'_var(--et-wght,400)] [will-change:font-variation-settings] motion-reduce:[will-change:auto]\";\n\ntype SegmentProps = {\n  segment: string;\n  index: number;\n  minWeight: number;\n  maxWeight: number;\n  reducedMotion: boolean;\n  mode: ElasticTextMode;\n  spotlight: ReturnType<typeof useMotionValue<number>>;\n  pointerX: ReturnType<typeof useMotionValue<number>>;\n  pointerActive: ReturnType<typeof useMotionValue<number>>;\n  getCenter: (index: number) => number;\n  radius: number;\n};\n\nfunction Segment({\n  segment,\n  index,\n  minWeight,\n  maxWeight,\n  reducedMotion,\n  mode,\n  spotlight,\n  pointerX,\n  pointerActive,\n  getCenter,\n  radius,\n}: SegmentProps) {\n  const autoWeight = useTransform(spotlight, (position) => {\n    const distance = Math.abs(index - position);\n    const influence = clamp(1 - distance / AUTO_SPREAD, 0, 1);\n    return lerp(minWeight, maxWeight, influence);\n  });\n\n  const hoverWeight = useTransform([pointerX, pointerActive], (latest) => {\n    const [x, active] = latest as [number, number];\n    if (!active) {\n      return minWeight;\n    }\n    const distance = Math.abs(x - getCenter(index));\n    const influence = clamp(1 - distance / radius, 0, 1);\n    return lerp(minWeight, maxWeight, influence);\n  });\n\n  const rawWeight = mode === \"hover\" ? hoverWeight : autoWeight;\n  const weight = useSpring(rawWeight, SPRING);\n\n  if (reducedMotion) {\n    return (\n      <span\n        className={SEGMENT_CLASS}\n        data-elastic-segment=\"\"\n        style={{ \"--et-wght\": minWeight } as React.CSSProperties}\n      >\n        {segment}\n      </span>\n    );\n  }\n\n  return (\n    <motion.span\n      className={SEGMENT_CLASS}\n      data-elastic-segment=\"\"\n      style={{ \"--et-wght\": weight } as React.CSSProperties}\n      aria-hidden={segment.trim() === \"\" ? true : undefined}\n    >\n      {segment}\n    </motion.span>\n  );\n}\n\nconst ElasticText = React.forwardRef<HTMLSpanElement, ElasticTextProps>(\n  (\n    {\n      children,\n      className,\n      mode = \"auto\",\n      minWeight = 300,\n      maxWeight = 900,\n      duration = 2,\n      loop = true,\n      startOnView = true,\n      radius = 120,\n      ...props\n    },\n    ref,\n  ) => {\n    const reducedMotion = useReducedMotion() ?? false;\n    const containerRef = React.useRef<HTMLSpanElement>(null);\n    const mergedRef = React.useCallback(\n      (node: HTMLSpanElement | null) => {\n        containerRef.current = node;\n        if (typeof ref === \"function\") {\n          ref(node);\n        } else if (ref) {\n          ref.current = node;\n        }\n      },\n      [ref],\n    );\n\n    const textContent = getTextContent(children);\n    const segments = React.useMemo(\n      () => (textContent ? [...textContent] : null),\n      [textContent],\n    );\n\n    // Start off the left edge so the first paint is uniform normal weight — at\n    // position 0 the leading character would render at max weight (a \"big P\"\n    // flash) before the sweep effect runs.\n    const spotlight = useMotionValue(-AUTO_SPREAD);\n    const pointerX = useMotionValue(0);\n    const pointerActive = useMotionValue(0);\n    const centersRef = React.useRef<number[]>([]);\n    const getCenter = React.useCallback(\n      (index: number) => centersRef.current[index] ?? 0,\n      [],\n    );\n\n    // Auto mode: sweep the spotlight across the characters. When `startOnView`\n    // is set, hold off until the text scrolls into view via IntersectionObserver.\n    React.useEffect(() => {\n      if (reducedMotion || mode !== \"auto\" || !segments) {\n        return;\n      }\n      const last = Math.max(segments.length - 1, 1);\n      // Rest off the left edge so every character sits at normal weight until\n      // the sweep actually starts (no leading-character flash while waiting).\n      spotlight.set(-AUTO_SPREAD);\n\n      const start = () =>\n        loop\n          ? // Loop: sweep back and forth forever.\n            animate(spotlight, [0, last], {\n              duration,\n              repeat: Number.POSITIVE_INFINITY,\n              repeatType: \"mirror\",\n              ease: \"easeInOut\",\n            })\n          : // Once: a single pass that starts and ends off the text (padded by\n            // AUTO_SPREAD on both sides) so the weight settles back to normal\n            // everywhere instead of leaving the leading characters emphasized.\n            animate(spotlight, [-AUTO_SPREAD, last + AUTO_SPREAD], {\n              duration,\n              ease: \"easeInOut\",\n            });\n\n      const node = containerRef.current;\n      if (\n        !startOnView ||\n        !node ||\n        typeof IntersectionObserver === \"undefined\"\n      ) {\n        const controls = start();\n        return () => controls.stop();\n      }\n\n      let controls: ReturnType<typeof animate> | undefined;\n      const observer = new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries) {\n            if (entry.isIntersecting) {\n              controls = start();\n              observer.disconnect();\n              break;\n            }\n          }\n        },\n        { threshold: VIEW_THRESHOLD },\n      );\n      observer.observe(node);\n      return () => {\n        observer.disconnect();\n        controls?.stop();\n      };\n    }, [duration, loop, mode, reducedMotion, segments, spotlight, startOnView]);\n\n    const updateCenters = React.useCallback(() => {\n      const container = containerRef.current;\n      if (!container) {\n        return;\n      }\n      const spans = container.querySelectorAll(\"[data-elastic-segment]\");\n      centersRef.current = Array.from(spans).map((span) => {\n        const rect = span.getBoundingClientRect();\n        return rect.left + rect.width / 2;\n      });\n    }, []);\n\n    React.useLayoutEffect(() => {\n      if (mode !== \"hover\") {\n        return;\n      }\n      updateCenters();\n      if (typeof window === \"undefined\") {\n        return;\n      }\n      window.addEventListener(\"resize\", updateCenters);\n      return () => window.removeEventListener(\"resize\", updateCenters);\n    }, [mode, updateCenters]);\n\n    const handleMouseMove = React.useCallback(\n      (event: React.MouseEvent<HTMLSpanElement>) => {\n        if (mode !== \"hover\") {\n          return;\n        }\n        pointerX.set(event.clientX);\n        updateCenters();\n      },\n      [mode, pointerX, updateCenters],\n    );\n\n    const interactionProps =\n      mode === \"hover\" && !reducedMotion\n        ? {\n            onMouseEnter: () => pointerActive.set(1),\n            onMouseLeave: () => pointerActive.set(0),\n            onMouseMove: handleMouseMove,\n          }\n        : undefined;\n\n    if (!segments) {\n      return (\n        <span\n          ref={mergedRef}\n          data-slot=\"elastic-text\"\n          className={`${CONTAINER_CLASS} ${className ?? \"\"}`}\n          style={{ \"--et-wght\": minWeight } as React.CSSProperties}\n          {...props}\n        >\n          {children}\n        </span>\n      );\n    }\n\n    return (\n      <span\n        ref={mergedRef}\n        data-slot=\"elastic-text\"\n        className={`${CONTAINER_CLASS} ${className ?? \"\"}`}\n        {...interactionProps}\n        {...props}\n      >\n        {segments.map((segment, index) => (\n          <Segment\n            // biome-ignore lint/suspicious/noArrayIndexKey: characters are positional\n            key={index}\n            segment={segment}\n            index={index}\n            minWeight={minWeight}\n            maxWeight={maxWeight}\n            reducedMotion={reducedMotion}\n            mode={mode}\n            spotlight={spotlight}\n            pointerX={pointerX}\n            pointerActive={pointerActive}\n            getCenter={getCenter}\n            radius={radius}\n          />\n        ))}\n      </span>\n    );\n  },\n);\nElasticText.displayName = \"ElasticText\";\n\nexport { ElasticText };\n",
      "type": "registry:ui",
      "target": "components/godui/elastic-text.tsx"
    },
    {
      "path": "packages/components/src/lib/text-utils.ts",
      "content": "import type { ReactNode } from \"react\";\n\nexport function getTextContent(children: ReactNode): string | null {\n  const text = collectText(children);\n  return text.length > 0 ? text : null;\n}\n\nfunction collectText(children: ReactNode): string {\n  if (children == null || typeof children === \"boolean\") {\n    return \"\";\n  }\n  if (typeof children === \"string\" || typeof children === \"number\") {\n    return String(children);\n  }\n  if (Array.isArray(children)) {\n    return children.map(collectText).join(\"\");\n  }\n  if (typeof children === \"object\" && \"props\" in children) {\n    const props = (children as { props?: { children?: ReactNode } }).props;\n    return collectText(props?.children ?? \"\");\n  }\n  return \"\";\n}\n\nexport function lerp(min: number, max: number, t: number): number {\n  return min + (max - min) * t;\n}\n\nexport function clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value));\n}\n",
      "type": "registry:ui",
      "target": "components/godui/text-utils.ts"
    }
  ],
  "type": "registry:ui"
}
