{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-shimmer","type":"registry:component","title":"Text shimmer","description":"Ongoing work said in words (“Thinking…”) with a calm band of light passing across them; stops when inactive.","categories":["display"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/text-shimmer/messages.ts","type":"registry:file","target":"src/ui/beamline/components/text-shimmer/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English defaults. */\nexport const english = {\n  durationParts: \"{minutes} {seconds}\",\n  took: \"took {duration}\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/components/text-shimmer/text-shimmer.module.css","type":"registry:file","target":"src/ui/beamline/components/text-shimmer/text-shimmer.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* the words carry the band of light (clipped to the glyphs) while work runs; when it ends their ink fades back in\n     over the band, so they settle instead of snapping */\n  .root {\n    display: inline-block;\n    background-image: linear-gradient(\n      90deg,\n      var(--pui-ink-3) 0%,\n      var(--pui-ink-3) 40%,\n      var(--pui-ink) 50%,\n      var(--pui-ink-3) 60%,\n      var(--pui-ink-3) 100%\n    );\n    background-size: 250% 100%;\n    background-clip: text;\n    color: var(--pui-ink-2);\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .root[data-moving] {\n    color: transparent;\n    transition-duration: 0ms;\n  }\n  /* the time and the room it holds share one cell: the cell is as wide as the wider of the two, so the time showing,\n     or growing from 9s to 10s, moves nothing; at a minute and at ten the room widens, eased (it clips while it eases) */\n  .elapsed {\n    display: inline-grid;\n    overflow: clip;\n    color: var(--pui-ink-3);\n    font-variant-numeric: tabular-nums;\n  }\n  .elapsed > [aria-hidden] {\n    grid-area: 1 / 1;\n    white-space: pre;\n  }\n  .room {\n    visibility: hidden;\n  }\n  .room::before {\n    content: attr(data-room);\n  }\n}\n"},{"path":"beamline/components/text-shimmer/text-shimmer.tsx","type":"registry:file","target":"src/ui/beamline/components/text-shimmer/text-shimmer.tsx","content":"\"use client\";\n\nimport { useMessages } from \"../../runtime/localization\";\nimport type { Translator } from \"../../runtime/messages\";\nimport { english } from \"./messages\";\nimport { useEffect, useRef, useState, type CSSProperties, type Ref } from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useActive } from \"../../hooks/use-active\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useWidthMorph } from \"../../hooks/use-width-morph\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./text-shimmer.module.css\";\n\nexport interface TextShimmerClassNames {\n  root?: string;\n  elapsed?: string;\n}\n\nexport interface TextShimmerProps {\n  children: string;\n  /** While work runs a calm band of light crosses the words; false shows plain text. Default true. */\n  running?: boolean;\n  /** Seconds per pass. Default 2. */\n  duration?: number;\n  /** After two seconds of work, how long it has taken (\"· 12s\"), counted while it runs and kept when it stops. */\n  elapsed?: boolean;\n  as?: \"span\" | \"div\" | \"p\" | \"h2\" | \"h3\" | \"h4\";\n  className?: string;\n  id?: string;\n  classNames?: TextShimmerClassNames;\n  style?: CSSProperties;\n  ref?: Ref<HTMLElement>;\n}\n\nfunction took(s: number, i18n: Translator<typeof english>, spoken = false) {\n  const part = (value: number, unit: \"second\" | \"minute\", pad = false) =>\n    i18n.number(value, {\n      style: \"unit\",\n      unit,\n      unitDisplay: spoken ? \"long\" : \"narrow\",\n      useGrouping: false,\n      minimumIntegerDigits: pad && !spoken ? 2 : 1,\n    });\n  return s < 60\n    ? part(s, \"second\")\n    : i18n(\"durationParts\", { minutes: part(Math.floor(s / 60), \"minute\"), seconds: part(s % 60, \"second\", true) });\n}\n\n/** Ongoing work said in words (\"Thinking…\", \"Syncing\"), with a band of light passing across them, in the reading\n *  direction. It costs a background-position animation and stops when it is not running or off screen; when the work\n *  ends the words settle into plain ink instead of snapping, and a screen reader hears the end once. With `elapsed`,\n *  work that takes longer than two seconds says how long. Reduced motion keeps the text still. */\nexport function TextShimmer({\n  children,\n  running = true,\n  duration = 2,\n  elapsed = false,\n  as: Tag = \"span\",\n  className,\n  id,\n  classNames,\n  style,\n  ref,\n}: TextShimmerProps) {\n  const i18n = useMessages(\"text-shimmer\", english);\n  const host = useRef<HTMLElement>(null);\n  const attach = useComposedRef(host, ref);\n  const active = useActive(host);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const moving = running && !(tokens.reduced || managedReduced);\n  // seconds of work: counted once a second while it runs (a timer that ends with the work), kept when it stops\n  const [seconds, setSeconds] = useState(0);\n  const [wasRunning, setWasRunning] = useState(running);\n  // a line that has run stays a status: the region is there before its words change, so the end is read once\n  const [ran, setRan] = useState(running);\n  if (running !== wasRunning) {\n    setWasRunning(running);\n    if (running) setSeconds(0);\n    if (running && !ran) setRan(true);\n  }\n  // the room changes shape only at a minute and at ten (· 00s → · 0m 00s → · 00m 00s): there it eases, so the\n  // line widens in a glide rather than in one frame; reduced motion and an off-screen line take it at once\n  const room = took(seconds < 60 ? 10 : seconds, i18n).replace(/\\p{Number}/gu, i18n.number(0));\n  const clock = useRef<HTMLSpanElement>(null);\n  useWidthMorph(clock, room);\n  useEffect(() => {\n    if (!elapsed || !running) return;\n    const start = Date.now();\n    // organism-allow: elapsed-copy -- the count holds its last value once the work stops (\"took 12s\")\n    const tick = window.setInterval(() => setSeconds(Math.floor((Date.now() - start) / 1000)), 1000);\n    return () => window.clearInterval(tick);\n  }, [elapsed, running]);\n  return (\n    <Tag\n      ref={attach}\n      id={id}\n      data-slot=\"text-shimmer\"\n      data-running={running ? \"\" : undefined}\n      data-moving={moving ? \"\" : undefined}\n      role={running || ran ? \"status\" : undefined}\n      className={cn(\n        styles.root,\n        moving && \"motion-safe:animate-[pui-shimmer_var(--_shimmer)_linear_infinite] rtl:[animation-direction:reverse]\",\n        classNames?.root,\n        className,\n      )}\n      // the pass length follows the global speed (O1 §14.1)\n      style={\n        {\n          \"--_shimmer\": `calc(${duration}s * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1))`,\n          animationPlayState: active ? \"running\" : \"paused\",\n          ...style,\n        } as CSSProperties\n      }\n    >\n      {children}\n      {elapsed && (running || seconds >= 2) && (\n        // its own ink, so the band never crosses it; silent while it counts (never announced every second), read\n        // once in words when the work ends. The room the time needs (\"· 00s\") is held from the start of the work, so\n        // nothing beside the line moves when the time shows at two seconds or goes from 9s to 10s\n        <span ref={clock} data-slot=\"text-shimmer-elapsed\" className={cn(styles.elapsed, classNames?.elapsed)}>\n          {/* drawn by CSS from data-room, so the held room adds no words to the line's text */}\n          <span aria-hidden className={styles.room} data-room={` · ${room}`} />\n          {seconds >= 2 && (\n            <span aria-hidden className=\"animate-enter-fade\">\n              {\" \"}\n              · {took(seconds, i18n)}\n            </span>\n          )}\n          {!running && seconds >= 2 && (\n            <span className=\"sr-only\">, {i18n(\"took\", { duration: took(seconds, i18n, true) })}</span>\n          )}\n        </span>\n      )}\n    </Tag>\n  );\n}\n\nexport default TextShimmer;\n"},{"path":"beamline/hooks/use-active.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-active.ts","content":"\"use client\";\n\nimport { useContext, useEffect, useState, type RefObject } from \"react\";\n\nimport { ActivityContext } from \"../runtime/activity\";\n\n/**\n * Whether an animated element should be doing work: it is (nearly) on screen and the tab is visible. Every\n * continuously animated component gates its loop, shader or CSS animation on this, so an effect scrolled out of view,\n * behind another tab, or in a collapsed panel costs nothing. One IntersectionObserver per element; no polling.\n */\nexport function useActive(\n  ref: RefObject<Element | null>,\n  {\n    margin = \"120px\",\n    key,\n    idle = true,\n  }: {\n    margin?: string;\n    /** Re-attach when this changes (an element that mounts later). */\n    key?: unknown;\n    /** Pause continuous work at the host idle boundary. Finite sequences set false so they finish. */\n    idle?: boolean;\n  } = {},\n) {\n  const active = useContext(ActivityContext);\n  const [inView, setInView] = useState(false);\n  const [visible, setVisible] = useState(\n    () => typeof document === \"undefined\" || document.visibilityState === \"visible\",\n  );\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      setInView(true);\n      return;\n    }\n    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: margin });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ref, margin, key]);\n  useEffect(() => {\n    const on = () => setVisible(document.visibilityState === \"visible\");\n    document.addEventListener(\"visibilitychange\", on);\n    return () => document.removeEventListener(\"visibilitychange\", on);\n  }, []);\n  return (!idle || active) && inView && visible;\n}\n"},{"path":"beamline/hooks/use-composed-ref.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-composed-ref.ts","content":"\"use client\";\n\nimport { useCallback, type Ref, type RefObject } from \"react\";\n\n/** Keep a control's measurement/focus ref and the caller's ref, including React 19 callback cleanup. */\nexport function useComposedRef<T>(internal: RefObject<T | null>, external?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      internal.current = node;\n      if (typeof external === \"function\") {\n        const cleanup = external(node);\n        if (typeof cleanup === \"function\")\n          return () => {\n            internal.current = null;\n            cleanup();\n          };\n      } else if (external) external.current = node;\n    },\n    [internal, external],\n  );\n}\n"},{"path":"beamline/hooks/use-media-query.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-media-query.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// one MediaQueryList per query: React reads the snapshot on every render, and a part that renders thirty times a\n// second (a playing audio player's clock) would otherwise ask the browser for a new list each time\nconst lists = new Map<string, MediaQueryList>();\nconst list = (query: string) => {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return undefined;\n  let m = lists.get(query);\n  if (!m) lists.set(query, (m = window.matchMedia(query)));\n  return m;\n};\n\n/** True while the media query matches; false on the server and in the first client render of SSR output. */\nexport function useMediaQuery(query: string): boolean {\n  return useSyncExternalStore(\n    (notify) => {\n      const m = list(query);\n      m?.addEventListener(\"change\", notify);\n      return () => m?.removeEventListener(\"change\", notify);\n    },\n    () => list(query)?.matches ?? false,\n    () => false,\n  );\n}\n\n/** Phone-width layouts: below 768px. */\nexport const usePhone = () => useMediaQuery(\"(max-width: 767px)\");\n"},{"path":"beamline/hooks/use-motion-tokens.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-motion-tokens.ts","content":"\"use client\";\n\nimport { useMemo, type RefObject } from \"react\";\nimport { useAppearanceRoot } from \"../runtime/appearance\";\nimport { useResolvedAppearance } from \"./use-resolved-appearance\";\nimport { motionAt, motionEase, motionTokens, type MotionTokens } from \"../lib/motion-tokens\";\nimport { semanticMotion } from \"../lib/motion-source\";\n\nconst durationToken = (name: string) =>\n  name === \"exit\" ? \"motion-out\" : name === \"in\" || name === \"out\" ? `motion-${name}` : `duration-${name}`;\nconst easeToken = (name: string) => `ease-${name === \"inOut\" ? \"in-out\" : name}`;\nconst REQUEST = {\n  times: [\n    ...Object.keys(semanticMotion.duration).map(durationToken),\n    ...Object.keys(semanticMotion.spring).map((name) => `spring-${name}`),\n    ...Object.keys(semanticMotion.stagger).map((name) => `stagger-${name}`),\n    \"fade-in\",\n    \"fade-out\",\n  ],\n  easings: [...Object.keys(semanticMotion.ease).map(easeToken), \"ease-spring\", \"ease-spring-bounce\", \"ease-overshoot\"],\n  numbers: [\"motion-scale\", \"motion-look\", \"press-scale\"],\n  // timing only: the host's own type would make every control resolve differently from the page around it\n  typography: false,\n};\n\n/**\n * Resolve each timing at the actual host; omitted host uses the nearest managed root. The first/server snapshot is\n * the pure foundation default. Local CSS values, scope changes, font readiness, OS preference and explicit refresh\n * update the same consumer; no document-wide first-root fallback or persistent global listener.\n */\nexport function useMotionTokens(hostRef?: RefObject<HTMLElement | null>): MotionTokens & {\n  ready: boolean;\n  /** Reduced or none: movement, springs and staggers are instant. */\n  reduced: boolean;\n  mode: \"full\" | \"reduced\" | \"none\";\n  /** Opacity-only arrival and departure (seconds): kept under reduced, instant under none (APPEARANCE A06). */\n  fade: { in: number; out: number };\n} {\n  const root = useAppearanceRoot();\n  const scopeRef = useMemo(() => ({ current: root }), [root]);\n  const resolved = useResolvedAppearance(hostRef ?? scopeRef, REQUEST);\n  return useMemo(() => {\n    if (!resolved.ready)\n      return {\n        ...motionTokens,\n        ready: false,\n        reduced: false,\n        mode: \"full\" as const,\n        fade: { in: motionTokens.duration.in, out: motionTokens.duration.out },\n      };\n    const reduced = resolved.reducedMotion;\n    const speed = (resolved.numbers[\"motion-scale\"] ?? 1) * (resolved.numbers[\"motion-look\"] ?? 1);\n    const fade =\n      resolved.motionMode === \"none\"\n        ? { in: 0.001, out: 0.001 }\n        : {\n            in: resolved.times[\"fade-in\"] ?? semanticMotion.duration.in * speed,\n            out: resolved.times[\"fade-out\"] ?? semanticMotion.duration.out * speed,\n          };\n    const factor = reduced ? 0.001 / semanticMotion.duration.standard : speed;\n    const base = motionAt(factor);\n    const duration = Object.fromEntries(\n      Object.entries(base.duration).map(([name, fallback]) => [\n        name,\n        reduced ? 0.001 : (resolved.times[durationToken(name)] ?? fallback),\n      ]),\n    ) as MotionTokens[\"duration\"];\n    const stagger = Object.fromEntries(\n      Object.entries(base.stagger).map(([name, fallback]) => [\n        name,\n        reduced ? 0 : (resolved.times[`stagger-${name}`] ?? fallback),\n      ]),\n    ) as MotionTokens[\"stagger\"];\n    const ease = Object.fromEntries(\n      Object.entries(base.ease).map(([name, fallback]) => [\n        name,\n        motionEase(resolved.easings[easeToken(name)], fallback),\n      ]),\n    ) as MotionTokens[\"ease\"];\n    const spring = { ...base.spring };\n    for (const name of Object.keys(semanticMotion.spring) as (keyof typeof semanticMotion.spring)[])\n      spring[name] = {\n        ...base.spring[name],\n        visualDuration: reduced ? 0.001 : (resolved.times[`spring-${name}`] ?? base.spring[name].visualDuration),\n        bounce: reduced ? 0 : base.spring[name].bounce,\n      };\n    return {\n      ...base,\n      ready: true,\n      reduced,\n      mode: resolved.motionMode,\n      fade,\n      duration,\n      stagger,\n      ease,\n      spring,\n      pressScale: resolved.numbers[\"press-scale\"] ?? base.pressScale,\n      css: {\n        spring: resolved.easings[\"ease-spring\"] ?? base.css.spring,\n        springBounce: resolved.easings[\"ease-spring-bounce\"] ?? base.css.springBounce,\n        overshoot: resolved.easings[\"ease-overshoot\"] ?? base.css.overshoot,\n      },\n    };\n  }, [resolved]);\n}\n"},{"path":"beamline/hooks/use-reduced-motion-safe.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-reduced-motion-safe.ts","content":"\"use client\";\n\nimport { useMediaQuery } from \"./use-media-query\";\nimport { useScopeMotion } from \"../runtime/appearance\";\n\n/** Managed motion policy plus the OS preference, with a stable server snapshot. */\nexport function useReducedMotionSafe() {\n  const requested = useScopeMotion();\n  const os = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  return requested !== \"auto\" || os;\n}\n"},{"path":"beamline/hooks/use-width-morph.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-width-morph.ts","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, type RefObject } from \"react\";\nimport { useMotionTokens } from \"./use-motion-tokens\";\nimport { cssEase } from \"../lib/motion-tokens\";\n\n/**\n * A label whose text changes keeps its path: when `key` changes, the element's width eases from what it was to what the\n * new text needs, so whatever sits after it (a chevron, a counter) glides instead of jumping. The element clips while it\n * eases (give it `overflow-clip whitespace-nowrap`). Nothing runs at rest; reduced motion and motion \"none\" switch at\n * once, and so does a label off screen.\n */\nexport function useWidthMorph(ref: RefObject<HTMLElement | null>, key: unknown) {\n  const motion = useMotionTokens(ref);\n  const last = useRef<number | null>(null);\n  const easing = useRef<Animation | null>(null);\n  // the width it last rested at, kept current when it changes on its own (a web font arriving) so the next ease starts\n  // where the label really is; the ease's own frames are not a resting width\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (!easing.current) last.current = el.getBoundingClientRect().width;\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // mid-way through an earlier change, it starts from where it is now, not from where that change was going\n    const from = easing.current ? el.getBoundingClientRect().width : last.current;\n    easing.current?.cancel();\n    easing.current = null;\n    const box = el.getBoundingClientRect();\n    const to = box.width;\n    last.current = to;\n    const ms = motion.duration.morph * 1000;\n    if (from === null || Math.abs(from - to) < 0.5 || motion.reduced || ms < 2) return;\n    // off screen nobody sees the glide: the label takes its width at once and no frame is spent on it\n    if (box.bottom < 0 || box.top > innerHeight || box.right < 0 || box.left > innerWidth) return;\n    const ease = motion.ease.default;\n    // pixel to pixel: a Web Animations keyframe to \"auto\" (with interpolate-size, or calc-size) jumps straight to the\n    // end in Chromium 153 instead of easing (measured: 537 of 537 px at 300 of 1000 ms); a late change of the natural\n    // width is caught by the observer above when the ease ends\n    const animation = el.animate([{ width: `${from}px` }, { width: `${to}px` }], {\n      duration: ms,\n      // even in and out: the glide never moves a neighbour more than a pixel or two in one frame\n      easing: cssEase(ease, \"ease-in-out\"),\n    });\n    easing.current = animation;\n    void animation.finished.then(\n      () => {\n        if (easing.current !== animation) return;\n        easing.current = null;\n        last.current = el.getBoundingClientRect().width;\n      },\n      () => {},\n    );\n  }, [key]); // eslint-disable-line react-hooks/exhaustive-deps -- eases on a change of the text it stands for\n}\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Text shimmer is in src/ui/beamline/components/text-shimmer/text-shimmer.tsx. The source is in src/ui/beamline (ui/beamline in a project without src/). After Tailwind, import its styles/beamline.css once, then styles/look-prism.css in your app stylesheet (use relative paths). Keep the shared base before all chosen looks. Wrap your app in <UIRoot preset={prism}> with UIRoot from its runtime/appearance and prism from its looks/prism.  shadcn replaces files when asked; use the Beamline managed-source CLI from the start for three-way upgrades that preserve edits.","meta":{"free":true,"version":"0.2.0-rc.42","library":"https://beamline.io/components/text-shimmer"}}