{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dia-text-reveal",
  "type": "registry:ui",
  "title": "Dia Text Reveal",
  "description": "A horizontal color band sweeps across text, revealing a gradient shine before settling on the base color.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/magicui/dia-text-reveal.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport {\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type HTMLMotionProps,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DEFAULT_COLORS = [\"#c679c4\", \"#fa3d1d\", \"#ffb005\", \"#e1e1fe\", \"#0358f7\"]\nconst BAND_HALF = 17\nconst SWEEP_START = -BAND_HALF\nconst SWEEP_END = 100 + BAND_HALF\n\nconst sweepEase = (t: number) =>\n  t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2\n\nfunction buildGradient(pos: number, colors: string[], textColor: string) {\n  const bandStart = pos - BAND_HALF\n  const bandEnd = pos + BAND_HALF\n\n  if (bandStart >= 100) {\n    return `linear-gradient(90deg, ${textColor}, ${textColor})`\n  }\n  const n = colors.length\n  const parts: string[] = []\n\n  if (bandStart > 0)\n    parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)\n\n  colors.forEach((c, i) => {\n    const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2\n    parts.push(`${c} ${pct.toFixed(2)}%`)\n  })\n\n  if (bandEnd < 100)\n    parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)\n\n  return `linear-gradient(90deg, ${parts.join(\", \")})`\n}\n\nfunction measureWidths(el: HTMLElement, texts: string[]) {\n  const ghost = el.cloneNode() as HTMLElement\n  Object.assign(ghost.style, {\n    position: \"absolute\",\n    visibility: \"hidden\",\n    pointerEvents: \"none\",\n    width: \"auto\",\n    whiteSpace: \"nowrap\",\n  })\n  el.parentElement!.appendChild(ghost)\n  const widths = texts.map((t) => {\n    ghost.textContent = t\n    return ghost.getBoundingClientRect().width\n  })\n  ghost.remove()\n  return widths\n}\n\n/**\n * Props for {@link DiaTextReveal}.\n */\nexport interface DiaTextRevealProps extends Omit<\n  HTMLMotionProps<\"span\">,\n  \"ref\" | \"children\" | \"style\" | \"animate\" | \"transition\" | \"color\"\n> {\n  /**\n   * Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.\n   */\n  text: string | string[]\n  /**\n   * Colors sampled across the moving gradient band. Defaults to a built-in palette.\n   */\n  colors?: string[]\n  /**\n   * CSS color for revealed text after the sweep and for leading/trailing regions during the animation.\n   * @defaultValue `\"var(--foreground)\"`\n   */\n  textColor?: string\n  /**\n   * Duration of one sweep pass, in seconds.\n   * @defaultValue `1.5`\n   */\n  duration?: number\n  /**\n   * Delay before the sweep starts, in seconds.\n   * @defaultValue `0`\n   */\n  delay?: number\n  /**\n   * When `text` is an array, replay the sweep and advance to the next string after each completion.\n   * @defaultValue `false`\n   */\n  repeat?: boolean\n  /**\n   * Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.\n   * @defaultValue `0.5`\n   */\n  repeatDelay?: number\n  /**\n   * If `true`, the animation starts only after the element enters the viewport.\n   * @defaultValue `true`\n   */\n  startOnView?: boolean\n  /**\n   * Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).\n   * @defaultValue `true`\n   */\n  once?: boolean\n  /**\n   * Additional class names for the animated `span` (e.g. typography utilities).\n   */\n  className?: string\n  /**\n   * When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.\n   * @defaultValue `false`\n   */\n  fixedWidth?: boolean\n}\n\nexport function DiaTextReveal({\n  text,\n  colors = DEFAULT_COLORS,\n  textColor = \"var(--foreground)\",\n  duration = 1.5,\n  delay = 0,\n  repeat = false,\n  repeatDelay = 0.5,\n  startOnView = true,\n  once = true,\n  className,\n  fixedWidth = false,\n  ...props\n}: DiaTextRevealProps) {\n  const texts = Array.isArray(text) ? text : [text]\n  const isMulti = texts.length > 1\n  const prefersReducedMotion = useReducedMotion()\n\n  const spanRef = useRef<HTMLSpanElement>(null)\n  const optsRef = useRef({\n    colors,\n    textColor,\n    duration,\n    delay,\n    repeat,\n    repeatDelay,\n    texts,\n  })\n  optsRef.current = {\n    colors,\n    textColor,\n    duration,\n    delay,\n    repeat,\n    repeatDelay,\n    texts,\n  }\n\n  const indexRef = useRef(0)\n  const hasPlayedRef = useRef(false)\n  const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)\n  const playRef = useRef<() => void>(null!)\n  const stopRef = useRef<(() => void) | null>(null)\n\n  const [activeIndex, setActiveIndex] = useState(0)\n  const [measuredWidths, setMeasuredWidths] = useState<number[]>([])\n\n  const sweepPos = useMotionValue(SWEEP_START)\n\n  const backgroundImage = useTransform(sweepPos, (pos) =>\n    buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)\n  )\n\n  const isInView = useInView(spanRef, { once, amount: 0.1 })\n\n  useEffect(() => {\n    const el = spanRef.current\n    if (!el || !isMulti) return\n    setMeasuredWidths(measureWidths(el, texts))\n  }, [Array.isArray(text) ? text.join(\"\\0\") : text])\n\n  playRef.current = () => {\n    const { duration, delay, repeat, repeatDelay, texts } = optsRef.current\n\n    sweepPos.set(SWEEP_START)\n\n    const controls = animate(sweepPos, SWEEP_END, {\n      duration,\n      delay,\n      ease: sweepEase,\n      onComplete() {\n        if (!repeat) return\n        timerRef.current = setTimeout(() => {\n          const next = (indexRef.current + 1) % texts.length\n          indexRef.current = next\n          setActiveIndex(next)\n          playRef.current()\n        }, repeatDelay * 1000)\n      },\n    })\n\n    stopRef.current = () => controls.stop()\n  }\n\n  useEffect(() => {\n    if (prefersReducedMotion) {\n      sweepPos.set(SWEEP_END)\n      return\n    }\n    if (startOnView && !isInView) return\n    if (once && hasPlayedRef.current) return\n    hasPlayedRef.current = true\n    playRef.current()\n\n    return () => {\n      stopRef.current?.()\n      clearTimeout(timerRef.current)\n    }\n  }, [isInView, startOnView, once, prefersReducedMotion, sweepPos])\n\n  const fixedW =\n    isMulti && fixedWidth && measuredWidths.length > 0\n      ? Math.max(...measuredWidths)\n      : undefined\n\n  const animatedW =\n    isMulti && !fixedWidth && measuredWidths[activeIndex] != null\n      ? measuredWidths[activeIndex]\n      : undefined\n\n  return (\n    <motion.span\n      ref={spanRef}\n      className={cn(\"align-bottom leading-[100%] text-inherit\", className)}\n      style={{\n        transform: \"translateY(-2px)\",\n        color: \"transparent\",\n        backgroundClip: \"text\",\n        WebkitBackgroundClip: \"text\",\n        backgroundSize: \"100% 100%\",\n        backgroundImage,\n        ...(isMulti && {\n          display: \"inline-block\",\n          overflow: \"hidden\",\n          whiteSpace: \"nowrap\",\n          verticalAlign: \"text-center\",\n          ...(fixedW != null && { width: fixedW }),\n        }),\n      }}\n      animate={animatedW != null ? { width: animatedW } : undefined}\n      transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}\n      {...props}\n    >\n      {texts[activeIndex]}\n    </motion.span>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}