{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-effects",
  "type": "registry:ui",
  "title": "Text Effects",
  "description": "Accessible React text effects for Next.js and Tailwind CSS: a shimmer, a word-by-word reveal and a typewriter, all real text and off with reduced motion.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/text-effects.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { blur, duration, ease } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  Fragment,\n  type HTMLAttributes,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\n\n/*\n * Text Effects (owner's roadmap, 2026-10-16: \"Shimmer, reveal and typewriter text\"): three calm ways to bring\n * text in, each in one neutral hue and off with reduced motion. The words stay real text for search engines and\n * screen readers.\n * - TextShimmer: a sheen of `foreground` sweeps across text in `muted-foreground`, the lightness of one hue,\n *   never a colour. A copy of the text in the light colour sits inside a soft-edged band (a static mask); the band\n *   slides across while the copy slides back the same amount, so the copy stays put and only the light moves.\n *   Transforms only, on the compositor, not an animated background. It keeps Skeleton's pace (a 1.5s sweep, a\n *   0.5s rest) and its clock, so a shimmering label sweeps in step with the skeletons beside it.\n * - TextReveal: words (or characters) rise 12px through the blur, 0.05s apart (motion.md's list items), once in\n *   view or on mount. The pieces are aria-hidden; a visually hidden copy carries the text. They're hidden in the\n *   server's HTML too, so a reveal above the fold never flashes in place before it plays.\n * - Typewriter: types each phrase, holds it, deletes it and types the next. The caret is solid while it types and\n *   breathes (an eased fade, not a hard blink) while it waits; typing a phrase once, it fades away at the end.\n *   Screen readers aren't sent the typing: a visually hidden copy says every phrase once, and nothing is a live\n *   region, because announcing a phrase every few seconds, forever, would talk over the page. It rests while\n *   off-screen.\n */\n\nconst subscribe = () => () => {};\n/** False on the server and while hydrating, so the server's HTML is what hydrates */\nconst useHydrated = () =>\n  useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n\nconst cubic = (curve: readonly number[]) => `cubic-bezier(${curve.join(\", \")})`;\n\n/** Hands a node to our ref and to the caller's */\nfunction useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      own.current = node;\n      if (typeof theirs === \"function\") theirs(node);\n      else if (theirs) theirs.current = node;\n    },\n    [own, theirs],\n  );\n}\n\n/* ----------------------------------------------------------------------------------------------------------- */\n\n/** Skeleton's sheen: a 2s period, sweeping for the first 75% of it */\nconst SHIMMER_PERIOD = duration.slower * 4 * 1000;\nconst SHIMMER_SWEEP = 0.75;\n/** The light is brightest in the band's middle and gone a quarter of the way in from each side */\nconst band = {\n  maskImage: \"linear-gradient(to right, transparent 25%, black, transparent 75%)\",\n  WebkitMaskImage: \"linear-gradient(to right, transparent 25%, black, transparent 75%)\",\n} as const;\n\nexport type TextShimmerProps = ComponentProps<\"span\">;\n\n/**\n * Text with a light sweeping across it, for a label that says something is happening (\"Generating…\"). It takes\n * the text's size; the colours are `muted-foreground` with a `foreground` sheen.\n */\nfunction TextShimmer({ className, children, ...props }: TextShimmerProps) {\n  const reduce = useReducedMotion() ?? false;\n  const light = useRef<HTMLSpanElement>(null);\n  const copy = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const sweep = light.current;\n    const still = copy.current;\n    if (!sweep || !still || reduce || typeof sweep.animate !== \"function\") return;\n    // From the start of the line in either writing direction\n    const [from, to] = getComputedStyle(sweep).direction === \"rtl\" ? [\"100%\", \"-100%\"] : [\"-100%\", \"100%\"];\n    const back = (value: string) => (value.startsWith(\"-\") ? value.slice(1) : `-${value}`);\n    const timing = { duration: SHIMMER_PERIOD, iterations: Number.POSITIVE_INFINITY };\n    const easing = cubic(ease.inOut);\n    const animations = [\n      sweep.animate(\n        [{ translate: from, easing }, { translate: to, offset: SHIMMER_SWEEP }, { translate: to }],\n        timing,\n      ),\n      // The copy goes back as far as the band goes on, so the letters never move\n      still.animate(\n        [\n          { translate: back(from), easing },\n          { translate: back(to), offset: SHIMMER_SWEEP },\n          { translate: back(to) },\n        ],\n        timing,\n      ),\n    ];\n    // Counted from the page's start, like Skeleton's sheen, so every shimmer and skeleton sweeps together\n    for (const animation of animations) animation.startTime = 0;\n    return () => {\n      for (const animation of animations) animation.cancel();\n    };\n  }, [reduce]);\n\n  return (\n    <span\n      data-slot=\"text-shimmer\"\n      className={cn(\"relative inline-block text-muted-foreground\", className)}\n      {...props}\n    >\n      {children}\n      {/* The light: a copy of the text in the foreground colour, seen through a band that sweeps across */}\n      <span aria-hidden className=\"pointer-events-none absolute inset-0 overflow-hidden select-none\">\n        <span ref={light} className=\"absolute inset-0 -translate-x-full rtl:translate-x-full\" style={band}>\n          <span\n            ref={copy}\n            className=\"absolute inset-0 translate-x-full text-foreground rtl:-translate-x-full\"\n          >\n            {children}\n          </span>\n        </span>\n      </span>\n    </span>\n  );\n}\n\n/* ----------------------------------------------------------------------------------------------------------- */\n\n/** motion.md's list items: a 12px rise through the blur, 0.05s apart */\nconst REVEAL_RISE = \"translateY(12px)\";\nconst REVEAL_STAGGER = 0.05;\n\ntype RevealTag = \"p\" | \"span\" | \"div\" | \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\" | \"blockquote\" | \"li\";\n\nexport type TextRevealProps = Omit<HTMLAttributes<HTMLElement>, \"children\"> & {\n  /** The text to reveal */\n  children: string;\n  /** The element to render, for example \"h2\" for a heading */\n  as?: RevealTag;\n  /** Reveal word by word, or character by character (for short headings) */\n  by?: \"word\" | \"character\";\n  /** Seconds before the first piece rises */\n  delay?: number;\n  /** Reveal once it scrolls into view. With false, it reveals on mount */\n  startOnView?: boolean;\n  ref?: Ref<HTMLElement>;\n};\n\n/**\n * Text that rises into place a word (or a character) at a time, once. Screen readers and search engines read the\n * whole text, as it is.\n */\nfunction TextReveal({\n  children,\n  as: Comp = \"p\",\n  by = \"word\",\n  delay = 0,\n  startOnView = true,\n  className,\n  ref,\n  ...props\n}: TextRevealProps) {\n  const reduce = useReducedMotion() ?? false;\n  const root = useRef<HTMLElement | null>(null);\n  const setRef = useMergedRef(root, ref);\n  const animations = useRef<Animation[]>([]);\n  // Until it's revealed, every piece is held at opacity 0, from the server's HTML on\n  const [shown, setShown] = useState(false);\n  const settings = useRef({ delay, startOnView, reduce });\n  settings.current = { delay, startOnView, reduce };\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node || shown) return;\n    if (settings.current.reduce || typeof node.animate !== \"function\") {\n      setShown(true);\n      return;\n    }\n    const reveal = () => {\n      const easing = cubic(ease.out);\n      for (const animation of animations.current) animation.cancel();\n      animations.current = Array.from(\n        node.querySelectorAll<HTMLElement>(\"[data-slot=text-reveal-piece]\"),\n        (piece, index) =>\n          piece.animate(\n            [\n              { opacity: 0, filter: blur.hidden, transform: REVEAL_RISE },\n              { opacity: 1, filter: blur.shown, transform: \"none\" },\n            ],\n            {\n              duration: duration.slow * 1000,\n              delay: Math.round((settings.current.delay + index * REVEAL_STAGGER) * 1000),\n              easing,\n              // Holds each piece hidden while it waits; at the end it lets go, leaving no filter behind\n              fill: \"backwards\",\n            },\n          ),\n      );\n      // The pieces drop their inline opacity in the same frame, and the animations take over\n      setShown(true);\n    };\n    if (!settings.current.startOnView || typeof IntersectionObserver === \"undefined\") {\n      reveal();\n      return;\n    }\n    const observer = new IntersectionObserver((entries) => {\n      if (!entries.some((entry) => entry.isIntersecting)) return;\n      observer.disconnect();\n      reveal();\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [shown]);\n\n  useEffect(\n    () => () => {\n      for (const animation of animations.current) animation.cancel();\n    },\n    [],\n  );\n\n  const hidden = shown ? undefined : { opacity: 0 };\n  const words = children.split(/\\s+/).filter(Boolean);\n\n  return (\n    <Comp ref={setRef} data-slot=\"text-reveal\" className={className} {...props}>\n      <span className=\"sr-only select-none\">{children}</span>\n      <span aria-hidden data-slot=\"text-reveal-text\">\n        {words.map((word, index) => (\n          // biome-ignore lint/suspicious/noArrayIndexKey: the same word can come twice; its place is its identity\n          <Fragment key={index}>\n            {index > 0 ? \" \" : null}\n            {by === \"word\" ? (\n              <span data-slot=\"text-reveal-piece\" className=\"inline-block\" style={hidden}>\n                {word}\n              </span>\n            ) : (\n              // A word's characters never break apart across lines\n              <span className=\"inline-block whitespace-nowrap\">\n                {Array.from(word).map((char, place) => (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: characters repeat; their place is their identity\n                  <span key={place} data-slot=\"text-reveal-piece\" className=\"inline-block\" style={hidden}>\n                    {char}\n                  </span>\n                ))}\n              </span>\n            )}\n          </Fragment>\n        ))}\n      </span>\n    </Comp>\n  );\n}\n\n/* ----------------------------------------------------------------------------------------------------------- */\n\n/** The caret's breath while it waits: in and out over a second */\nconst CARET_BREATH = duration.slower * 2 * 1000;\n\ntype Typing = {\n  phrase: number;\n  length: number;\n  /** starting: before the first character · between: after deleting, before the next phrase */\n  mode: \"starting\" | \"typing\" | \"holding\" | \"deleting\" | \"between\" | \"done\";\n};\n\nexport type TypewriterProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  /** One phrase, or several to type in turn */\n  phrases: string | readonly string[];\n  /** Go back to the first phrase after the last. On by default for several phrases, off for one */\n  loop?: boolean;\n  /** Seconds per character typed */\n  typeSpeed?: number;\n  /** Seconds per character deleted */\n  deleteSpeed?: number;\n  /** Seconds a typed phrase stays before it's deleted */\n  hold?: number;\n  /** Seconds before the first character, once it's in view */\n  delay?: number;\n  /** Show the caret */\n  cursor?: boolean;\n};\n\n/**\n * Types a phrase, or several in turn, with a soft caret. It starts once it's in view and rests while it's\n * off-screen. Screen readers hear every phrase once, as plain text.\n */\nfunction Typewriter({\n  phrases,\n  loop,\n  typeSpeed = duration.instant,\n  deleteSpeed = duration.instant / 2,\n  hold = duration.slower * 4,\n  delay = 0,\n  cursor = true,\n  className,\n  ref,\n  ...props\n}: TypewriterProps) {\n  const list = typeof phrases === \"string\" ? [phrases] : [...phrases];\n  const loops = loop ?? list.length > 1;\n  const reduce = useReducedMotion() ?? false;\n  // With reduced motion the first phrase is simply there; the server's HTML (still empty) hydrates first\n  const still = useHydrated() && reduce;\n  const root = useRef<HTMLSpanElement | null>(null);\n  const setRef = useMergedRef(root, ref);\n  const caret = useRef<HTMLSpanElement>(null);\n  const caretAnimation = useRef<Animation | null>(null);\n  const [typing, setTyping] = useState<Typing>({ phrase: 0, length: 0, mode: \"starting\" });\n  const [inView, setInView] = useState(false);\n  const settings = useRef({ list, loops, typeSpeed, deleteSpeed, hold, delay });\n  settings.current = { list, loops, typeSpeed, deleteSpeed, hold, delay };\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const observer = new IntersectionObserver((entries) => {\n      const entry = entries.at(-1);\n      if (entry) setInView(entry.isIntersecting);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  // One step at a time: a character typed or deleted, a hold, a pause between phrases\n  useEffect(() => {\n    if (still || !inView || typing.mode === \"done\") return;\n    const { list, loops, typeSpeed, deleteSpeed, hold, delay } = settings.current;\n    const total = Array.from(list[typing.phrase] ?? \"\").length;\n    const last = typing.phrase >= list.length - 1;\n    let wait: number;\n    let next: Typing;\n    switch (typing.mode) {\n      case \"starting\":\n      case \"between\":\n        wait = typing.mode === \"starting\" ? delay : duration.slow;\n        next = { ...typing, mode: total > 0 ? \"typing\" : \"holding\" };\n        break;\n      case \"typing\": {\n        const length = typing.length + 1;\n        wait = typeSpeed;\n        next = { ...typing, length, mode: length >= total ? \"holding\" : \"typing\" };\n        break;\n      }\n      case \"holding\":\n        wait = hold;\n        next = last && !loops ? { ...typing, mode: \"done\" } : { ...typing, mode: \"deleting\" };\n        break;\n      default: {\n        const length = typing.length - 1;\n        wait = deleteSpeed;\n        next =\n          length > 0\n            ? { ...typing, length }\n            : { phrase: last ? 0 : typing.phrase + 1, length: 0, mode: \"between\" };\n      }\n    }\n    const timer = setTimeout(() => setTyping(next), wait * 1000);\n    return () => clearTimeout(timer);\n  }, [typing, inView, still]);\n\n  // The caret: solid while the text moves, breathing while it waits, fading away when it's done\n  const caretMode =\n    typing.mode === \"typing\" || typing.mode === \"deleting\"\n      ? \"solid\"\n      : typing.mode === \"done\"\n        ? \"gone\"\n        : \"wait\";\n  useEffect(() => {\n    const node = caret.current;\n    if (!node || typeof node.animate !== \"function\") return;\n    // Read before cancelling, so the fade at the end starts from wherever the breath was\n    const opacity = getComputedStyle(node).opacity || \"1\";\n    caretAnimation.current?.cancel();\n    caretAnimation.current = null;\n    if (caretMode === \"wait\") {\n      caretAnimation.current = node.animate([{ opacity: 1 }, { opacity: 0 }, { opacity: 1 }], {\n        duration: CARET_BREATH,\n        iterations: Number.POSITIVE_INFINITY,\n        easing: cubic(ease.inOut),\n      });\n    } else if (caretMode === \"gone\") {\n      // The end state, kept until it unmounts\n      caretAnimation.current = node.animate([{ opacity }, { opacity: 0 }], {\n        duration: duration.slow * 1000,\n        easing: cubic(ease.out),\n        fill: \"forwards\",\n      });\n    }\n  }, [caretMode]);\n  useEffect(() => () => caretAnimation.current?.cancel(), []);\n\n  const typed = still\n    ? (list[0] ?? \"\")\n    : Array.from(list[typing.phrase] ?? \"\")\n        .slice(0, typing.length)\n        .join(\"\");\n\n  return (\n    <span ref={setRef} data-slot=\"typewriter\" className={cn(\"whitespace-pre-wrap\", className)} {...props}>\n      <span className=\"sr-only select-none\">{list.join(\", \")}</span>\n      <span aria-hidden data-slot=\"typewriter-text\">\n        {typed}\n      </span>\n      {cursor && !still ? (\n        // A 2px bar one line of the text tall: the zero-width space gives it the font's height and baseline\n        <span\n          ref={caret}\n          aria-hidden\n          data-slot=\"typewriter-caret\"\n          className=\"ms-0.5 inline-block w-0.5 rounded-full bg-current leading-none select-none\"\n        >\n          {\"​\"}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nexport { TextReveal, TextShimmer, Typewriter };\n"
    }
  ],
  "categories": [
    "effects"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/text-effects"
}
