{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dia-text-reveal",
  "title": "Dia Text Reveal",
  "description": "A gradient sweep that reveals text with a chromatic wash, powered by Motion.",
  "dependencies": ["motion"],
  "registryDependencies": ["utils"],
  "files": [
    {
      "path": "registry/primitives/texts/dia-text-reveal/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  animate,\n  type MotionProps,\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ComponentType,\n  type ElementType,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\ntype EaseValue =\n  | \"linear\"\n  | \"easeIn\"\n  | \"easeOut\"\n  | \"easeInOut\"\n  | \"circIn\"\n  | \"circOut\"\n  | \"circInOut\"\n  | \"backIn\"\n  | \"backOut\"\n  | \"backInOut\"\n  | \"anticipate\"\n  | readonly [number, number, number, number]\n  | ((t: number) => number);\n\nexport interface DiaTextRevealHandle {\n  /** (Re)starts the sweep immediately, ignoring startOnView/inView gating. */\n  play: () => void;\n  /** Resets to the first phrase and starts the sweep immediately. */\n  replay: () => void;\n}\n\nexport interface DiaTextRevealProps {\n  /** Render as a different element/component instead of `span`. */\n  as?: ElementType;\n  className?: string;\n  colors?: string[];\n  delay?: number;\n  /** Sweep direction across the text. @default \"ltr\" */\n  direction?: \"ltr\" | \"rtl\";\n  duration?: number;\n  /** Easing for the reveal sweep. */\n  ease?: EaseValue;\n  fadeDuration?: number;\n  /** Easing for the fade-out between repeats. */\n  fadeEase?: EaseValue;\n  /** Lock width to the longest phrase so rotating text does not shift layout. */\n  fixedWidth?: boolean;\n  holdDuration?: number;\n  inViewMargin?:\n    | `${number}px`\n    | `${number}px ${number}px`\n    | `${number}px ${number}px ${number}px`\n    | `${number}px ${number}px ${number}px ${number}px`;\n  /** Called every time a phrase finishes revealing (fully visible). */\n  onComplete?: () => void;\n  once?: boolean;\n  ref?: Ref<DiaTextRevealHandle>;\n  repeat?: boolean;\n  repeatDelay?: number;\n  startOnView?: boolean;\n  text: string | string[];\n  /** Final revealed text color. @default \"currentColor\" */\n  textColor?: string;\n}\n\nconst DEFAULT_EASE: EaseValue = [0.23, 1, 0.32, 1];\n\nfunction buildGradient(colors: string[], textColor: string, angle: number) {\n  const bandStart = 40;\n  const bandEnd = 60;\n  const stops = colors.map((color, index) => {\n    const t = colors.length === 1 ? 0.5 : index / (colors.length - 1);\n    const pct = bandStart + t * (bandEnd - bandStart);\n    return `${color} ${pct}%`;\n  });\n\n  // First third = final text color, middle = chromatic ribbon, last third =\n  // transparent (hidden). Start at background-position 100% (transparent),\n  // animate to 0%. `angle` flips which edge the reveal starts from.\n  return `linear-gradient(${angle}deg, ${textColor} 0%, ${textColor} 33.33%, ${stops.join(\", \")}, transparent 66.67%, transparent 100%)`;\n}\n\nfunction measureMaxWidth(element: HTMLElement, texts: string[]) {\n  const ghost = element.cloneNode() as HTMLElement;\n\n  Object.assign(ghost.style, {\n    position: \"absolute\",\n    visibility: \"hidden\",\n    pointerEvents: \"none\",\n    width: \"auto\",\n    whiteSpace: \"nowrap\",\n  });\n\n  element.parentElement?.appendChild(ghost);\n\n  let max = 0;\n\n  for (const entry of texts) {\n    ghost.textContent = entry;\n    max = Math.max(max, ghost.getBoundingClientRect().width);\n  }\n\n  ghost.remove();\n  return max;\n}\n\nexport function DiaTextReveal({\n  text,\n  colors = [\"#c679c4\", \"#fa3d1d\", \"#ffb005\", \"#e1e1fe\", \"#0358f7\"],\n  textColor = \"currentColor\",\n  direction = \"ltr\",\n  duration = 1.5,\n  delay = 0,\n  ease = DEFAULT_EASE,\n  fadeEase = \"easeInOut\",\n  repeat = false,\n  repeatDelay = 0.5,\n  holdDuration = 1,\n  fadeDuration = 0.6,\n  fixedWidth = false,\n  startOnView = true,\n  once = true,\n  inViewMargin = \"0px\",\n  onComplete,\n  as: Component = \"span\",\n  className,\n  ref: controlRef,\n}: DiaTextRevealProps) {\n  const elementRef = useRef<HTMLElement>(null);\n  const isInView = useInView(elementRef, {\n    once,\n    margin: inViewMargin,\n  });\n  const prefersReducedMotion = useReducedMotion();\n  const canAnimate = !prefersReducedMotion && (!startOnView || isInView);\n\n  const texts = useMemo(() => (Array.isArray(text) ? text : [text]), [text]);\n  const isMulti = texts.length > 1;\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [lockedWidth, setLockedWidth] = useState<number | undefined>();\n  const indexRef = useRef(0);\n  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  // sweep: 100 = hidden, 0 = fully revealed. Position and opacity are driven\n  // independently so the gradient only ever resets back to 100 while the\n  // text is fully invisible — the reverse sweep is never actually seen.\n  const sweep = useMotionValue(100);\n  const textOpacity = useMotionValue(0);\n  const backgroundPosition = useTransform(sweep, (v) => `${v}% 50%`);\n  const angle = direction === \"rtl\" ? 270 : 90;\n\n  useLayoutEffect(() => {\n    const element = elementRef.current;\n\n    if (!(element && fixedWidth && isMulti)) {\n      setLockedWidth(undefined);\n      return;\n    }\n\n    setLockedWidth(measureMaxWidth(element, texts));\n  }, [fixedWidth, isMulti, texts]);\n\n  const clearCycle = useCallback(() => {\n    sweep.stop();\n    textOpacity.stop();\n\n    if (timerRef.current) {\n      clearTimeout(timerRef.current);\n    }\n\n    timerRef.current = undefined;\n  }, [sweep, textOpacity]);\n\n  const playRef = useRef<() => void>(() => undefined);\n\n  playRef.current = () => {\n    clearCycle();\n    sweep.set(100);\n    textOpacity.set(0);\n\n    animate(sweep, 0, { duration, delay, ease });\n    animate(textOpacity, 1, {\n      duration,\n      delay,\n      ease,\n      onComplete() {\n        onComplete?.();\n\n        if (!repeat) {\n          return;\n        }\n\n        timerRef.current = setTimeout(() => {\n          animate(textOpacity, 0, {\n            duration: fadeDuration,\n            ease: fadeEase,\n            onComplete() {\n              indexRef.current = (indexRef.current + 1) % texts.length;\n              setActiveIndex(indexRef.current);\n              sweep.set(100);\n\n              timerRef.current = setTimeout(() => {\n                playRef.current();\n              }, repeatDelay * 1000);\n            },\n          });\n        }, holdDuration * 1000);\n      },\n    });\n  };\n\n  const replay = useCallback(() => {\n    if (prefersReducedMotion) {\n      return;\n    }\n\n    indexRef.current = 0;\n    setActiveIndex(0);\n    playRef.current();\n  }, [prefersReducedMotion]);\n\n  const play = useCallback(() => {\n    if (prefersReducedMotion) {\n      return;\n    }\n\n    playRef.current();\n  }, [prefersReducedMotion]);\n\n  useImperativeHandle(controlRef, () => ({ play, replay }), [play, replay]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-run only when visibility or text list changes\n  useEffect(() => {\n    indexRef.current = 0;\n    setActiveIndex(0);\n    clearCycle();\n    sweep.set(100);\n    textOpacity.set(0);\n\n    if (canAnimate) {\n      playRef.current();\n    }\n\n    return clearCycle;\n  }, [canAnimate, texts]);\n\n  const MotionComponent = useMemo(\n    () =>\n      motion.create(Component as never) as ComponentType<\n        MotionProps & {\n          children?: ReactNode;\n          className?: string;\n          ref?: Ref<HTMLElement>;\n        }\n      >,\n    [Component]\n  );\n  const resolvedColor = textColor === \"currentColor\" ? \"inherit\" : textColor;\n\n  return (\n    <MotionComponent\n      className={cn(\"inline-block bg-clip-text\", className)}\n      ref={elementRef}\n      style={\n        prefersReducedMotion\n          ? {\n              color: resolvedColor,\n              WebkitTextFillColor: \"transparent\",\n              backgroundImage: buildGradient(colors, textColor, angle),\n              backgroundSize: \"300% 100%\",\n              backgroundPosition: \"0% 50%\",\n              opacity: 1,\n              ...(lockedWidth != null && {\n                width: lockedWidth,\n                whiteSpace: \"nowrap\",\n              }),\n            }\n          : {\n              color: resolvedColor,\n              WebkitTextFillColor: \"transparent\",\n              backgroundImage: buildGradient(colors, textColor, angle),\n              backgroundSize: \"300% 100%\",\n              backgroundPosition,\n              opacity: textOpacity,\n              ...(lockedWidth != null && {\n                width: lockedWidth,\n                whiteSpace: \"nowrap\",\n              }),\n            }\n      }\n    >\n      {prefersReducedMotion ? texts[0] : texts[activeIndex]}\n    </MotionComponent>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/sora-ui/texts/dia-text-reveal.tsx"
    }
  ],
  "meta": {
    "inspiration": {
      "type": "reimplemented",
      "label": "Dia Text",
      "url": "https://iconiqui.com/texts/dia-text",
      "stack": "Motion and React"
    }
  },
  "type": "registry:ui"
}
