{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-loader","type":"registry:component","title":"Text Loader","description":"Letters rise out of a mask, fill with a gradient, leave, and the panel lifts away.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/text-loader/text-loader.tsx","type":"registry:component","target":"components/dimaac/text-loader.tsx","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A preloader. The letters rise one by one out of a mask, a gradient wipes up through each of them,\n// they leave through the top, and then the whole panel lifts off the page. It uses the font of the\n// page it sits in, so there is no font to install.\n\nexport type TextLoaderProps = {\n  /** The word the loader spells out. One letter is one animated piece. */\n  text?: string;\n  /** Colours of the gradient that fills the letters (45 degrees, one gradient per letter). Two or more. */\n  gradientColors?: string[];\n  /** Colour of the panel. */\n  backgroundColor?: string;\n  /** Colour of the letters before the gradient fills them. Leave it out and it follows the panel: a light tint on a dark panel, a dark tint on a light one. */\n  ghostColor?: string;\n  /** How long each step takes, in seconds. Any you leave out keep their default. */\n  duration?: {\n    /** Letters rising into view. */\n    slideUp?: number;\n    /** The gradient filling the letters. */\n    reveal?: number;\n    /** Letters leaving through the top. */\n    slideDown?: number;\n  };\n  /** The pauses in between, in seconds. Any you leave out keep their default. */\n  delays?: {\n    /** Gap between one letter and the next. */\n    stagger?: number;\n    /** Pause between the letters landing and the gradient starting. */\n    betweenAnimations?: number;\n    /** Pause between the gradient finishing and the letters leaving. */\n    beforeSlideDown?: number;\n  };\n  /** Cover the whole window (position: fixed) instead of filling the box it sits in. */\n  fullscreen?: boolean;\n  /** Change this number to play the loader again. */\n  replayKey?: number;\n  /** Called once the panel has left the screen. A new function on every render is fine. */\n  onComplete?: () => void;\n  /** Extra classes for the panel. */\n  className?: string;\n};\n\n// Module constants, so the defaults are the same object on every render\nconst DEFAULT_COLORS = [\"#ff0000\", \"#ff3333\", \"#ff6600\", \"#cc0000\"];\nconst DEFAULT_DURATION = { slideUp: 0.6, reveal: 0.8, slideDown: 0.6 };\nconst DEFAULT_DELAYS = { stagger: 0.05, betweenAnimations: 0.3, beforeSlideDown: 0.5 };\n\n// Like { ...defaults, ...given }, except a value that is undefined falls back to its default\nfunction withDefaults<T extends Record<string, number>>(defaults: T, given: Partial<T> = {}): T {\n  const merged = { ...defaults };\n  for (const key of Object.keys(defaults) as (keyof T)[]) {\n    const value = given[key];\n    if (value !== undefined) merged[key] = value;\n  }\n  return merged;\n}\n\n// Picks the ghost letters from the panel colour, so a light panel never hides them.\n// Only #rgb and #rrggbb are read; any other colour format falls back to the light tint.\nfunction ghostFor(background: string): string {\n  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(background.trim())?.[1];\n  if (!hex) return \"rgba(255, 255, 255, 0.2)\";\n  const full = hex.length === 3 ? Array.from(hex, (c) => c + c).join(\"\") : hex;\n  const channel = (i: number) => parseInt(full.slice(i, i + 2), 16);\n  const [r, g, b] = [channel(0), channel(2), channel(4)];\n  const light = (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;\n  return light ? \"rgba(0, 0, 0, 0.14)\" : \"rgba(255, 255, 255, 0.2)\";\n}\n\nexport function TextLoader({\n  text = \"DIMAAC\",\n  gradientColors = DEFAULT_COLORS,\n  backgroundColor = \"#17171a\",\n  ghostColor,\n  duration,\n  delays,\n  fullscreen = false,\n  replayKey = 0,\n  onComplete,\n  className = \"\",\n}: TextLoaderProps) {\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  // A partly filled object still works: whatever is missing comes from the defaults\n  const { slideUp, reveal, slideDown } = withDefaults(DEFAULT_DURATION, duration);\n  const { stagger, betweenAnimations, beforeSlideDown } = withDefaults(DEFAULT_DELAYS, delays);\n\n  // The newest onComplete lives in a ref. The animation reads it when it ends, so a parent that\n  // re-renders (and passes a new function) never restarts the loader.\n  const onCompleteRef = useRef(onComplete);\n  useEffect(() => {\n    onCompleteRef.current = onComplete;\n  });\n\n  const letters = Array.from(text);\n  const gradient = `linear-gradient(45deg, ${(gradientColors.length > 1 ? gradientColors : DEFAULT_COLORS).join(\", \")})`;\n\n  useGSAP(\n    () => {\n      const panel = panelRef.current;\n      if (!panel) return;\n      const glyphs = gsap.utils.toArray<HTMLElement>(\"[data-glyph]\", panel);\n      const fills = gsap.utils.toArray<HTMLElement>(\"[data-fill]\", panel);\n      const finish = () => {\n        // Off screen already, but also out of reach of the keyboard and screen readers\n        gsap.set(panel, { visibility: \"hidden\" });\n        onCompleteRef.current?.();\n      };\n\n      const mm = gsap.matchMedia();\n\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        gsap.set(panel, { y: 0, yPercent: 0, visibility: \"visible\" });\n        gsap.set(glyphs, { y: 0, yPercent: 110 });\n        gsap.set(fills, { clipPath: \"inset(100% 0% 0% 0%)\" });\n\n        const tl = gsap.timeline({ onComplete: finish });\n        tl.to(glyphs, { yPercent: 0, duration: slideUp, stagger, ease: \"power2.out\" })\n          .to(fills, { clipPath: \"inset(0% 0% 0% 0%)\", duration: reveal, delay: betweenAnimations, ease: \"power1.inOut\" })\n          .to(glyphs, { yPercent: -110, duration: slideDown, stagger, delay: beforeSlideDown, ease: \"power2.in\" })\n          .to(panel, { yPercent: -100, duration: 0.8, delay: 0.3, ease: \"power2.inOut\" }, \"-=0.2\");\n      });\n\n      // Reduced motion: nothing moves. The word shows filled for a moment, then the panel fades away.\n      mm.add(\"(prefers-reduced-motion: reduce)\", () => {\n        gsap.set(panel, { y: 0, yPercent: 0, visibility: \"visible\", opacity: 1 });\n        gsap.set(glyphs, { y: 0, yPercent: 0 });\n        gsap.set(fills, { clipPath: \"inset(0% 0% 0% 0%)\" });\n        gsap.to(panel, { opacity: 0, duration: 0.4, delay: 0.9, ease: \"none\", onComplete: finish });\n      });\n\n      return () => mm.revert();\n    },\n    // Only the numbers and the text restart it. Colours are plain styles, so they change live.\n    { scope: panelRef, dependencies: [text, slideUp, reveal, slideDown, stagger, betweenAnimations, beforeSlideDown, replayKey], revertOnUpdate: true },\n  );\n\n  // One size rule: it grows with the panel's width, and shrinks for long words so they always fit\n  const fontSize = `clamp(2rem, min(20cqi, ${(88 / (Math.max(letters.length, 3) * 0.66)).toFixed(2)}cqi), 8rem)`;\n\n  return (\n    <div\n      ref={panelRef}\n      role=\"status\"\n      className={`${fullscreen ? \"fixed\" : \"absolute\"} inset-0 z-50 flex items-center justify-center overflow-hidden ${className}`}\n      style={{ backgroundColor, containerType: \"inline-size\" }}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <div aria-hidden=\"true\" className=\"flex overflow-hidden px-[0.04em]\" style={{ fontSize, fontWeight: 700, lineHeight: 1.05, letterSpacing: \"-0.01em\" }}>\n        {letters.map((letter, index) => (\n          <span\n            key={`${letter}-${index}`}\n            data-glyph\n            className=\"relative inline-block whitespace-pre\"\n            style={{ color: ghostColor ?? ghostFor(backgroundColor), transform: \"translateY(110%)\" }}\n          >\n            {letter}\n            {/* The same letter on top, filled with the gradient and wiped in from the bottom */}\n            <span\n              data-fill\n              className=\"absolute inset-0\"\n              style={{ backgroundImage: gradient, backgroundClip: \"text\", WebkitBackgroundClip: \"text\", WebkitTextFillColor: \"transparent\", clipPath: \"inset(100% 0% 0% 0%)\" }}\n            >\n              {letter}\n            </span>\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["loaders","gsap","loader","preloader","text","gradient"],"docs":"import { TextLoader } from \"@/components/dimaac/text-loader\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/text-loader","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/text-loader","credits":[{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/an-empty-snowy-mountain-with-pink-sunrise-on-the-hori-270fdea6-887e-4d89-8205-3eab907f3fb0-HgoCN","what":"Page photo","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"}]}}