{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-reveal",
  "title": "Text Reveal",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Reveals a headline or paragraph word by word or character by character with a soft blur and rise, when it scrolls into view or on mount.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/text-reveal.tsx",
      "content": "// Ballmac UI: Text Reveal. https://ui.ballmac.com/components/text-reveal\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useInView, type Variants } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { stagger, variants } from \"@/lib/ballmac/motion\"\n\ntype TextRevealElement = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\"\n\ntype TextRevealProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The text to reveal. Plain text only, so it can be split and read out once. */\n  children: string\n  /** Split into words (default) or single characters. */\n  by?: \"word\" | \"char\"\n  /** The element to render. Defaults to \"p\". */\n  as?: TextRevealElement\n  /** \"inView\" (default) waits until the text scrolls into view; \"mount\" starts right away. */\n  trigger?: \"inView\" | \"mount\"\n  /** Play only the first time the text enters the viewport. Defaults to true. */\n  once?: boolean\n  /** Seconds before the first piece appears. */\n  delay?: number\n  /** Seconds between pieces. Defaults to 0.06 for words and 0.018 for characters. */\n  step?: number\n}\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const media = window.matchMedia(REDUCED_QUERY)\n  media.addEventListener(\"change\", onChange)\n  return () => media.removeEventListener(\"change\", onChange)\n}\n\n/** Reduced-motion preference that is false on the server and during hydration, so markup always matches. */\nfunction useReducedMotionSafe() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false\n  )\n}\n\nfunction TextReveal({\n  children,\n  by = \"word\",\n  as = \"p\",\n  trigger = \"inView\",\n  once = true,\n  delay = 0,\n  step,\n  className,\n  ...props\n}: TextRevealProps) {\n  const Comp = as as React.ElementType\n  const ref = React.useRef<HTMLElement>(null)\n  const inView = useInView(ref, { once, amount: 0.4 })\n  const reduceMotion = useReducedMotionSafe()\n  const text = children\n\n  const container = React.useMemo<Variants>(\n    () => ({\n      hidden: {},\n      visible: { transition: stagger(step ?? (by === \"char\" ? 0.018 : 0.06), delay) },\n    }),\n    [by, step, delay]\n  )\n\n  if (reduceMotion) {\n    return (\n      <Comp ref={ref} data-slot=\"text-reveal\" className={className} {...props}>\n        {text}\n      </Comp>\n    )\n  }\n\n  const show = trigger === \"mount\" || inView\n  // Split on whitespace but keep it, so the browser still wraps lines normally.\n  const tokens = text.split(/(\\s+)/).filter(Boolean)\n\n  return (\n    <Comp ref={ref} data-slot=\"text-reveal\" className={className} {...props}>\n      <span className=\"sr-only\">{text}</span>\n      <motion.span\n        aria-hidden=\"true\"\n        data-slot=\"text-reveal-content\"\n        initial=\"hidden\"\n        animate={show ? \"visible\" : \"hidden\"}\n        variants={container}\n      >\n        {tokens.map((token, i) => {\n          if (/^\\s+$/.test(token)) return \" \"\n          if (by === \"word\") {\n            return (\n              <motion.span key={i} className=\"inline-block\" variants={variants.fadeUp}>\n                {token}\n              </motion.span>\n            )\n          }\n          return (\n            // Keep each word's characters together so lines never break mid-word.\n            <span key={i} className=\"inline-block whitespace-nowrap\">\n              {Array.from(token).map((char, j) => (\n                <motion.span key={j} className=\"inline-block\" variants={variants.fadeUp}>\n                  {char}\n                </motion.span>\n              ))}\n            </span>\n          )\n        })}\n      </motion.span>\n    </Comp>\n  )\n}\n\nexport { TextReveal, type TextRevealProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/text-reveal.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "animation",
      "text",
      "headline",
      "scroll",
      "motion"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "Wrap a plain string: <TextReveal as=\"h1\">Ship the interface</TextReveal>. Screen readers get the full text once from a visually hidden copy; the animated pieces are aria-hidden. Under reduced motion it renders plain text.",
      "whenToUse": [
        "Hero headlines and section titles on marketing pages",
        "A short tagline that should land after the page loads (trigger=\"mount\")",
        "Pull quotes or statements revealed as the reader scrolls"
      ],
      "whenNotToUse": [
        "Body copy, UI labels or anything users need to read immediately",
        "Rich text with links or inline elements (children must be a plain string)",
        "Streaming AI output (render tokens as they arrive instead)"
      ],
      "composesWith": [
        "shimmer-text",
        "animated-grid",
        "number-ticker"
      ],
      "a11y": [],
      "customization": [
        "by: word | char (char staggers faster and keeps each word on one line)",
        "as: h1 | h2 | h3 | h4 | p | span | div",
        "trigger: inView | mount; once (default true) replays on re-entry when false",
        "delay and step in seconds; style with className like any text element"
      ]
    },
    "examples": [
      "text-reveal-demo",
      "text-reveal-chars"
    ],
    "url": "https://ui.ballmac.com/components/text-reveal"
  },
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}