{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-shimmer",
  "type": "registry:ui",
  "title": "Text shimmer",
  "description": "Show ongoing work with a calm light across the words.",
  "categories": [
    "text"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/text-shimmer/text-shimmer.tsx",
      "content": "\"use client\";\nimport { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  usePageInView,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport type { AnimationPlaybackControls } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\nconst subscribeHydration = () => () => {};\nconst clientSnapshot = () => true;\nconst serverSnapshot = () => false;\n/** Half width of the highlight band in em, and a bell shaped falloff as [offset, strength] stops. */\nconst band = 2.2;\nconst falloff = [\n  [-1, 0],\n  [-0.5, 0.5],\n  [-0.2, 0.9],\n  [0, 1],\n  [0.2, 0.9],\n  [0.5, 0.5],\n  [1, 0],\n] as const;\n/** The band is the current text color; the resting base leans toward muted text. Override with --text-shimmer-highlight and --text-shimmer-base. */\nconst colors =\n  \"[--ts-highlight:var(--text-shimmer-highlight,currentColor)] [--ts-base:var(--text-shimmer-base,color-mix(in_oklab,currentColor_12%,var(--ink-3)))]\";\n/** The label paints its text with the band. While the line springs to a new width it keeps its own line, so it never rewraps mid morph.\n *  Without color-mix or under forced colors it falls back to plain text. */\nconst label =\n  \"inline-block max-w-full bg-no-repeat bg-clip-text [-webkit-text-fill-color:transparent] selection:[-webkit-text-fill-color:currentColor] group-data-sizing/stage:max-w-none group-data-sizing/stage:whitespace-nowrap not-supports-[color:color-mix(in_oklab,red,blue)]:bg-none! not-supports-[color:color-mix(in_oklab,red,blue)]:[-webkit-text-fill-color:currentColor] forced-colors:bg-none! forced-colors:[-webkit-text-fill-color:currentColor]\";\nconst percent = (value: number) => `${(value * 100).toFixed(1)}%`;\n\n/** Paints the band at sweep position `t` (0 sits left of the text, 1 right of it) over a base that is `settle` of the way to solid. */\nfunction shimmerImage(t: number, settle: number) {\n  const base = `color-mix(in oklab, var(--ts-highlight) ${percent(settle)}, var(--ts-base))`;\n  const stops = falloff.map(\n    ([offset, strength]) =>\n      `color-mix(in oklab, var(--ts-highlight) ${percent(strength)}, ${base}) calc(${(t * 100).toFixed(2)}% + ${((2 * t - 1 + offset) * band).toFixed(3)}em)`,\n  );\n  return `linear-gradient(90deg, ${base}, ${stops.join(\", \")}, ${base})`;\n}\n\n/**\n * A calm light sweep across a short status line, such as \"Thinking\" or \"Generating summary\", to show that work is ongoing.\n * The band uses the current text color over a muted base. When `active` turns false the band glides off and the text settles to solid.\n * A changed label rises in place while the line springs to its new width. The component sets `aria-busy`; announce completion from your own live region.\n */\nexport interface TextShimmerProps {\n  /** The status text. Keep it to one short line. */\n  children: string;\n  /** Sweep while work is ongoing. Defaults to true. */\n  active?: boolean;\n  /** Seconds for one sweep across the text. Defaults to 1.8. */\n  duration?: number;\n  as?: \"span\" | \"p\" | \"div\" | \"h2\" | \"h3\" | \"h4\";\n  className?: string;\n  id?: string;\n}\nexport function TextShimmer({ children, active = true, duration = 1.8, as = \"span\", className, id }: TextShimmerProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const prefersReduced = useReducedMotion();\n  const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot);\n  const reduced = Boolean(hydrated && prefersReduced);\n  const inView = useInView(ref, { margin: \"64px\" });\n  const pageVisible = usePageInView();\n  const sweep = useMotionValue(0);\n  const settle = useMotionValue(active ? 0 : 1);\n  const backgroundImage = useTransform(() => shimmerImage(sweep.get(), settle.get()));\n  const running = active && !reduced && inView && pageVisible;\n  const restWidth = useRef(0);\n  const Tag = as;\n\n  // Loop the sweep only while work is ongoing, visible, and motion is allowed. A paused band resumes where it stopped.\n  useEffect(() => {\n    if (reduced) sweep.jump(0);\n    if (!running) return;\n    let controls: AnimationPlaybackControls | undefined;\n    const pass = (delay: number) => {\n      if (sweep.get() >= 1) sweep.jump(0);\n      controls = animate(sweep, 1, {\n        duration: duration * (1 - sweep.get()),\n        delay,\n        ease: \"linear\",\n        onComplete: () => pass(duration * 0.32),\n      });\n    };\n    pass(sweep.get() > 0 ? 0 : 0.12);\n    return () => controls?.stop();\n  }, [running, reduced, duration, sweep]);\n\n  // Finishing lets a band in flight glide off while the base resolves to solid, so the text never hard cuts.\n  useEffect(() => {\n    if (active) {\n      const dim = animate(settle, 0, {\n        duration: reduced ? 0 : motionTokens.duration.standard,\n        ease: [...motionTokens.ease.standard],\n      });\n      return () => dim.stop();\n    }\n    const t = sweep.get();\n    const glide =\n      !reduced && t > 0 && t < 1\n        ? animate(sweep, 1, { duration: Math.min(0.42, duration * (1 - t) * 0.5), ease: [...motionTokens.ease.enter] })\n        : undefined;\n    const solid = animate(settle, 1, {\n      duration: reduced ? motionTokens.duration.instant : 0.36,\n      ease: [...motionTokens.ease.standard],\n    });\n    return () => {\n      glide?.stop();\n      solid.stop();\n    };\n  }, [active, reduced, duration, sweep, settle]);\n\n  // A new label springs the line to its width instead of resizing in one frame, so text after it never jumps.\n  useLayoutEffect(() => {\n    const stage = ref.current;\n    if (!stage) return;\n    const sizing = Boolean(stage.style.width);\n    const current = stage.getBoundingClientRect().width;\n    stage.style.width = \"\";\n    const next = stage.getBoundingClientRect().width;\n    const from = sizing ? current : restWidth.current;\n    restWidth.current = next;\n    if (reduced || !from || Math.abs(from - next) < 0.5) {\n      delete stage.dataset.sizing;\n      return;\n    }\n    stage.dataset.sizing = \"\";\n    const controls = animate(\n      stage,\n      { width: [from, next] },\n      {\n        ...motionTokens.spring.morph,\n        onComplete: () => {\n          stage.style.width = \"\";\n          delete stage.dataset.sizing;\n        },\n      },\n    );\n    return () => controls.stop();\n  }, [children, reduced]);\n\n  // Keep the resting width current when fonts load or the container resizes.\n  useEffect(() => {\n    const stage = ref.current;\n    if (!stage || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (!stage.style.width) restWidth.current = stage.getBoundingClientRect().width;\n    });\n    observer.observe(stage);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <Tag\n      id={id}\n      className={cn(colors, className)}\n      data-state={active ? \"active\" : \"idle\"}\n      aria-busy={active || undefined}\n    >\n      <span ref={ref} className=\"group/stage relative inline-block max-w-full\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={children}\n            className={label}\n            style={{ backgroundImage }}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n            animate={reduced ? { opacity: 1 } : { opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n            exit={\n              reduced\n                ? { opacity: 0, transition: { duration: motionTokens.duration.instant } }\n                : {\n                    opacity: 0,\n                    y: \"-0.24em\",\n                    filter: `blur(${motionTokens.blur.subtle}px)`,\n                    transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n                  }\n            }\n            transition={{\n              duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard,\n              ease: [...motionTokens.ease.enter],\n            }}\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </Tag>\n  );\n}\n\nexport default TextShimmer;\n",
      "type": "registry:component",
      "target": "@components/iris/text-shimmer/text-shimmer.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/text-shimmer",
  "meta": {
    "kind": "component",
    "group": "Text & motion",
    "export": "TextShimmer",
    "tags": [
      "text",
      "motion",
      "loading"
    ],
    "docs": "https://irisui.dev/components/text-shimmer"
  }
}
