{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/text-shimmer/text-shimmer.tsx",
      "type": "registry:component",
      "target": "@components/xzero/text-shimmer/text-shimmer.tsx",
      "content": "\"use client\";\nimport { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, usePageInView, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./text-shimmer.module.css\";\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 = [[-1, 0], [-.5, .5], [-.2, .9], [0, 1], [.2, .9], [.5, .5], [1, 0]] as const;\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(([offset, strength]) => `color-mix(in oklab, var(--ts-highlight) ${percent(strength)}, ${base}) calc(${(t * 100).toFixed(2)}% + ${((2 * t - 1 + offset) * band).toFixed(3)}em)`);\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, { duration: duration * (1 - sweep.get()), delay, ease: \"linear\", onComplete: () => pass(duration * .32) });\n    };\n    pass(sweep.get() > 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, { duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.standard] });\n      return () => dim.stop();\n    }\n    const t = sweep.get();\n    const glide = !reduced && t > 0 && t < 1 ? animate(sweep, 1, { duration: Math.min(.42, duration * (1 - t) * .5), ease: [...motionTokens.ease.enter] }) : undefined;\n    const solid = animate(settle, 1, { duration: reduced ? motionTokens.duration.instant : .36, ease: [...motionTokens.ease.standard] });\n    return () => { glide?.stop(); solid.stop(); };\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) < .5) { delete stage.dataset.sizing; return; }\n    stage.dataset.sizing = \"\";\n    const controls = animate(stage, { width: [from, next] }, { ...motionTokens.spring.morph, onComplete: () => { stage.style.width = \"\"; delete stage.dataset.sizing; } });\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(() => { if (!stage.style.width) restWidth.current = stage.getBoundingClientRect().width; });\n    observer.observe(stage);\n    return () => observer.disconnect();\n  }, []);\n\n  return <Tag id={id} className={[styles.shimmer, className].filter(Boolean).join(\" \")} data-state={active ? \"active\" : \"idle\"} aria-busy={active || undefined}>\n    <span ref={ref} className={styles.stage}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span key={children} className={styles.label} 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={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, y: \"-0.24em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }}\n          transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{children}</motion.span>\n      </AnimatePresence>\n    </span>\n  </Tag>;\n}\n\nexport default TextShimmer;\n"
    },
    {
      "path": "registry/components/text-shimmer/text-shimmer.module.css",
      "type": "registry:component",
      "target": "@components/xzero/text-shimmer/text-shimmer.module.css",
      "content": "/* The band is the current text color; the resting base leans toward muted text. Override with --text-shimmer-highlight and --text-shimmer-base. */\n.shimmer { --ts-highlight: var(--text-shimmer-highlight, currentColor); --ts-base: var(--text-shimmer-base, color-mix(in oklab, currentColor 12%, var(--text-muted))); }\n.stage { position: relative; display: inline-block; max-width: 100%; }\n.label { display: inline-block; max-width: 100%; background-repeat: no-repeat; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }\n/* While the line springs to a new width the label keeps its own line, so it never rewraps mid morph. */\n.stage[data-sizing] .label { max-width: none; white-space: nowrap; }\n.label::selection { -webkit-text-fill-color: currentColor; }\n@supports not (color: color-mix(in oklab, red, blue)) { .label { background: none !important; -webkit-text-fill-color: currentColor; } }\n@media (forced-colors: active) { .label { background: none !important; -webkit-text-fill-color: currentColor; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/text-shimmer",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "text",
      "motion",
      "loading"
    ],
    "docs": "https://ui.xzero.co.uk/components/text-shimmer",
    "markdown": "https://ui.xzero.co.uk/components/text-shimmer/markdown"
  }
}