{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "hero-title-animation", "type": "registry:ui", "title": "Hero Title Animation", "description": "An animated hero title with phased brand reveal, staggered word blur-in, and highlight box sweep effect.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/hero-title-animation.tsx", "content": "\"use client\";\n\nimport {\n useState,\n useEffect,\n useCallback,\n useRef,\n useLayoutEffect,\n} from \"react\";\n\ninterface HeroTitleAnimationProps {\n className?: string;\n}\n\nexport function HeroTitleAnimation({ className }: HeroTitleAnimationProps) {\n const [phase, setPhase] = useState<\n \"measuring\" | \"fade-in\" | \"move\" | \"reveal\" | \"highlight\" | \"done\"\n >(\"measuring\");\n\n const [brandOffsetX, setBrandOffsetX] = useState(0);\n const [titleOffsetY, setTitleOffsetY] = useState(0);\n const [ready, setReady] = useState(false);\n\n const measureBrandRef = useRef(null);\n const containerRef = useRef(null);\n const timersRef = useRef([]);\n\n const brandWords = [\"Make\", \"your\", \"websites\"];\n const highlightWords = [\"feel\", \"premium\", \"instantly\"];\n\n const titleCx =\n \"flex flex-wrap items-baseline justify-center gap-x-[0.1em] gap-y-2 md:gap-y-3 text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold tracking-tight text-foreground leading-[1.15]\";\n\n /* ─── Measure BEFORE first paint ─── */\n useLayoutEffect(() => {\n const brand = measureBrandRef.current;\n const container = containerRef.current;\n if (!brand || !container) return;\n\n const brandRect = brand.getBoundingClientRect();\n const containerRect = container.getBoundingClientRect();\n\n const brandCenterX = brandRect.left + brandRect.width / 2;\n const containerCenterX = containerRect.left + containerRect.width / 2;\n setBrandOffsetX(containerCenterX - brandCenterX);\n\n const brandMidY = brandRect.top - containerRect.top + brandRect.height / 2;\n const containerMidY = containerRect.height / 2;\n setTitleOffsetY(containerMidY - brandMidY);\n\n setReady(true);\n }, []);\n\n const clearTimers = useCallback(() => {\n timersRef.current.forEach(clearTimeout);\n timersRef.current = [];\n }, []);\n\n const startSequence = useCallback(() => {\n clearTimers();\n setPhase(\"fade-in\");\n timersRef.current.push(setTimeout(() => setPhase(\"move\"), 1200));\n timersRef.current.push(setTimeout(() => setPhase(\"reveal\"), 1800));\n timersRef.current.push(setTimeout(() => setPhase(\"highlight\"), 2500));\n timersRef.current.push(setTimeout(() => setPhase(\"done\"), 3100));\n }, [clearTimers]);\n\n useEffect(() => {\n if (!ready) return;\n startSequence();\n return clearTimers;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [ready]);\n\n useEffect(() => clearTimers, [clearTimers]);\n\n /* ─── Derived flags ─── */\n const brandVisible = phase !== \"measuring\";\n const brandMoved =\n phase === \"move\" ||\n phase === \"reveal\" ||\n phase === \"highlight\" ||\n phase === \"done\";\n const showReveal =\n phase === \"reveal\" || phase === \"highlight\" || phase === \"done\";\n const showHighlight = phase === \"highlight\" || phase === \"done\";\n\n return (\n \n {/* ── Hidden sizing layer ── */}\n \n \n {brandWords.map((w, i) => (\n \n {w}\n \n ))}\n \n \n \n {highlightWords.map((w, i) => (\n \n {w}\n \n ))}\n \n \n \n\n {/* ── Visible animated title ── */}\n \n {/* \"Built for Your Foundation\" — fades in centered, then slides to position */}\n \n {brandWords.map((w, i) => (\n \n {w}\n \n ))}\n \n\n {/* \"of Design Engineering\" — reveals together, only \"Design Engineering\" gets highlight */}\n \n {/* Highlight sub-group */}\n \n {/* Highlight box sweeps left → right */}\n \n {highlightWords.map((word, i) => (\n \n {word}\n \n ))}\n \n \n \n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/hero-title-animation.tsx" } ] }