{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "millrace",
  "type": "registry:ui",
  "title": "Millrace",
  "description": "An infinite marquee whose speed and skew react to scroll velocity, smoothed so it never jitters. Stamped outline text fills bright white past a velocity threshold; hover slows it to a crawl, it idles offscreen, and reduced motion freezes it to a static outline row.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/scroll/millrace/millrace.tsx",
      "type": "registry:ui",
      "target": "components/crucible/millrace.tsx",
      "content": "\"use client\";\n\nimport { motion, useAnimationFrame, useInView, useMotionValue, useScroll, useTransform } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface MillraceProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Repeating content — text, logos, whatever. Duplicated internally for a seamless wrap. */\n  children: React.ReactNode;\n  /** How many copies of `children` to lay end to end. Increase for short content. @default 4 */\n  repeat?: number;\n  /** Idle drift speed in px/second when scroll is still. @default 40 */\n  baseVelocity?: number;\n  /** How strongly scroll velocity accelerates/reverses the drift. @default 4 */\n  velocityFactor?: number;\n  /** Max skew in degrees applied at high scroll velocity. @default 6 */\n  skewFactor?: number;\n  /** Base travel direction at rest. @default \"left\" */\n  direction?: \"left\" | \"right\";\n  /** Slow to a crawl on hover. @default true */\n  pauseOnHover?: boolean;\n  /**\n   * Track the scroll velocity of this element instead of the window. Pass a\n   * ref to an `overflow-y-auto` container to drive the race from an internal\n   * scroll region.\n   */\n  scroller?: React.RefObject<HTMLElement | null>;\n  /** Smoothed |velocity| (px/s) above which the fill ignites. @default 350 */\n  heatThreshold?: number;\n  /** Disable the velocity fill / smear / spark treatment entirely. @default true */\n  heatFill?: boolean;\n  /** Outline (resting) text color. @default \"#8a8a92\" (neutral) */\n  steelColor?: string;\n  /** Fill gradient stops, dim to bright. @default [\"#c9c9d2\", \"#ffffff\"] */\n  heatColors?: [string, string];\n}\n\n/** Wraps `v` into `[min, max)` — the seamless-loop modulo. */\nfunction wrapValue(min: number, max: number, v: number) {\n  const range = max - min;\n  if (range <= 0) return min;\n  return (((v - min) % range) + range) % range + min;\n}\n\nconst SPARKS = [\n  { top: \"18%\", delay: 0 },\n  { top: \"52%\", delay: 0.35 },\n  { top: \"78%\", delay: 0.7 },\n];\n\n/**\n * Millrace — an infinite marquee whose speed and skew react to scroll\n * velocity. Idles at a steady drift; scrolling accelerates and skews it,\n * damped through a spring so it never jitters.\n *\n * Crucible signature: content renders as stamped outline type that fills\n * bright white once velocity clears a friction threshold, settling back to\n * outline at rest, with a horizontal smear and spark flecks at speed. Under\n * reduced motion the race freezes to a static outline row with no drift, skew,\n * smear, or fill reaction. The frame loop idles while the marquee is offscreen\n * (and on hidden tabs, where rAF stops).\n */\nexport function Millrace({\n  children,\n  repeat = 4,\n  baseVelocity = 40,\n  velocityFactor = 4,\n  skewFactor = 6,\n  direction = \"left\",\n  pauseOnHover = true,\n  scroller,\n  heatThreshold = 350,\n  heatFill = true,\n  steelColor = \"#8a8a92\",\n  heatColors = [\"#c9c9d2\", \"#ffffff\"],\n  className,\n  ...props\n}: MillraceProps) {\n  const reducedMotion = useReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const inView = useInView(rootRef, { margin: \"100px\" });\n  const inViewRef = React.useRef(inView);\n  React.useEffect(() => {\n    inViewRef.current = inView;\n  }, [inView]);\n  const baseX = useMotionValue(0);\n  const hoveringRef = React.useRef(false);\n  const [unitWidth, setUnitWidth] = React.useState(0);\n\n  const { scrollY } = useScroll(scroller ? { container: scroller } : undefined);\n\n  // Exponentially-damped scroll velocity (px/s), sampled every frame — avoids\n  // the extra frame of lag a useVelocity(useSpring(...)) chain would add.\n  const velocityRef = React.useRef(0);\n  const lastScrollRef = React.useRef(0);\n  const lastTimeRef = React.useRef<number | null>(null);\n\n  const dirSign = direction === \"left\" ? -1 : 1;\n  const gapPx = 48;\n\n  React.useEffect(() => {\n    const node = trackRef.current;\n    if (!node) return;\n    const measure = () => setUnitWidth(node.scrollWidth / repeat);\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(node);\n    return () => ro.disconnect();\n  }, [repeat, children]);\n\n  const x = useTransform(baseX, (v) => (unitWidth ? `${wrapValue(-unitWidth, 0, v)}px` : \"0px\"));\n  const skew = useMotionValue(0);\n  const heat = useMotionValue(0);\n  const smear = useMotionValue(0);\n\n  useAnimationFrame((time, delta) => {\n    const dt = Math.min(delta, 48) / 1000;\n\n    // Reduced motion: static frame. Offscreen: idle (reset sampling so the\n    // first frame back doesn't read a giant velocity spike).\n    if (reducedMotion) return;\n    if (!inViewRef.current) {\n      lastTimeRef.current = null;\n      return;\n    }\n\n    // Sample raw scroll velocity (px/s) from the spring-smoothed position.\n    const current = scrollY.get();\n    if (lastTimeRef.current !== null) {\n      const elapsed = (time - lastTimeRef.current) / 1000;\n      if (elapsed > 0) {\n        const rawVelocity = (current - lastScrollRef.current) / elapsed;\n        velocityRef.current += (rawVelocity - velocityRef.current) * Math.min(1, elapsed * 6);\n      }\n    }\n    lastScrollRef.current = current;\n    lastTimeRef.current = time;\n\n    const v = velocityRef.current;\n    const clampedFactor = Math.max(-velocityFactor, Math.min(velocityFactor, (v / 1500) * velocityFactor));\n\n    // Hover slows the race to a crawl (10%) rather than a dead stop.\n    const hoverScale = hoveringRef.current && pauseOnHover ? 0.1 : 1;\n    const drift = dirSign * baseVelocity * dt;\n    const boost = clampedFactor * baseVelocity * dt;\n    baseX.set(baseX.get() + (drift + boost) * hoverScale);\n\n    const absV = Math.abs(v);\n    skew.set(Math.max(-skewFactor, Math.min(skewFactor, (v / 2000) * skewFactor)));\n    heat.set(Math.max(0, Math.min(1, (absV - heatThreshold) / (heatThreshold * 2))));\n    smear.set(Math.max(0, Math.min(3, absV / 900)));\n  });\n\n  const filter = useTransform(smear, (s) => (s > 0.05 ? `blur(${s.toFixed(2)}px)` : \"none\"));\n  const heatOpacity = useTransform(heat, (h) => h);\n  const sparkOpacity = useTransform(heat, (h) => (h > 0.35 ? 1 : 0));\n\n  const copies = Array.from({ length: repeat }, (_, i) => i);\n\n  return (\n    <div\n      ref={rootRef}\n      data-crucible=\"millrace\"\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      onMouseEnter={() => {\n        hoveringRef.current = true;\n      }}\n      onMouseLeave={() => {\n        hoveringRef.current = false;\n      }}\n      {...props}\n    >\n      <motion.div\n        ref={trackRef}\n        className=\"flex w-max whitespace-nowrap will-change-transform\"\n        style={{\n          x,\n          skewX: reducedMotion ? 0 : skew,\n          filter: reducedMotion || !heatFill ? \"none\" : filter,\n          gap: gapPx,\n          paddingInline: gapPx / 2,\n        }}\n      >\n        {copies.map((i) => (\n          <span key={i} className=\"relative inline-flex shrink-0 items-center\" aria-hidden={i > 0}>\n            <span\n              className=\"millrace-outline select-none text-4xl font-black uppercase tracking-tight sm:text-5xl\"\n              style={{\n                color: \"transparent\",\n                WebkitTextStroke: `1.5px ${steelColor}`,\n              }}\n            >\n              {children}\n            </span>\n            {heatFill && !reducedMotion && (\n              <motion.span\n                aria-hidden\n                className=\"millrace-fill pointer-events-none absolute inset-0 select-none text-4xl font-black uppercase tracking-tight sm:text-5xl\"\n                style={{\n                  opacity: heatOpacity,\n                  backgroundImage: `linear-gradient(90deg, ${heatColors[0]}, ${heatColors[1]})`,\n                  WebkitBackgroundClip: \"text\",\n                  backgroundClip: \"text\",\n                  color: \"transparent\",\n                  filter: `drop-shadow(0 0 10px ${heatColors[1]}66)`,\n                }}\n              >\n                {children}\n              </motion.span>\n            )}\n          </span>\n        ))}\n      </motion.div>\n\n      {heatFill && !reducedMotion && (\n        <div aria-hidden className=\"pointer-events-none absolute inset-y-0 right-0 w-16\">\n          {SPARKS.map((spark, i) => (\n            // Outer span gates visibility by heat (`style`); inner span owns the\n            // fall/flicker keyframe loop (`animate`) — kept on separate elements\n            // because an `animate` target always wins over a `style` value for\n            // the same key, which would otherwise erase the heat gating.\n            <motion.span key={i} className=\"absolute right-2\" style={{ top: spark.top, opacity: sparkOpacity }}>\n              <motion.span\n                className=\"block h-1 w-1 rounded-full\"\n                style={{ backgroundColor: heatColors[1], boxShadow: `0 0 6px 1px ${heatColors[1]}` }}\n                animate={{ y: [0, 10, 16], opacity: [0, 1, 0] }}\n                transition={{\n                  duration: 0.9,\n                  repeat: Infinity,\n                  ease: \"easeIn\",\n                  delay: spark.delay,\n                }}\n              />\n            </motion.span>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}