{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-reveal","type":"registry:component","title":"Text reveal","description":"A headline whose words rise into place once, the first time it scrolls into view; never repeats.","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-reveal/text-reveal.module.css","type":"registry:file","target":"src/ui/beamline/components/text-reveal/text-reveal.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* the heading role at the 2xl step, held here rather than as utilities: a caller's size (text-xl) then changes the\n     size only, and the role's face, weight, leading and balanced lines stay (a role class and a size class replace each\n     other in class merging) */\n  .root {\n    font-family: var(--pui-font-display);\n    font-stretch: var(--pui-heading-stretch);\n    font-size: var(--pui-text-2xl);\n    font-weight: var(--pui-weight-title);\n    line-height: var(--pui-leading-heading);\n    letter-spacing: -0.015em;\n    text-wrap: balance;\n    color: var(--pui-ink);\n  }\n}\n"},{"path":"beamline/components/text-reveal/text-reveal.tsx","type":"registry:file","target":"src/ui/beamline/components/text-reveal/text-reveal.tsx","content":"\"use client\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type Ref,\n} from \"react\";\nimport { useComposedRef } from \"../../hooks/use-composed-ref\";\nimport { useMotionTokens } from \"../../hooks/use-motion-tokens\";\nimport { useReducedMotionSafe } from \"../../hooks/use-reduced-motion-safe\";\nimport { cn } from \"../../lib/utils\";\nimport styles from \"./text-reveal.module.css\";\n\nexport interface TextRevealClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the word part; no override by default. */\n  word?: string;\n}\n\nexport interface TextRevealProps {\n  /** Text rendered by the component. Required. */\n  text: string;\n  /** HTML element used for the text or container. Defaults to \"h2\". */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\";\n  /** ms before the first word. Default 0. */\n  delay?: number;\n  /** Classes on the documented visual root; no override by default. */\n  className?: string;\n  /** DOM id on the animated text root; omitted by default. */\n  id?: string;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: TextRevealClassNames;\n  /** Inline styles on the documented visual root; no inline overrides by default. */\n  style?: CSSProperties;\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLElement>;\n}\n\nconst never = () => () => {};\n// isomorphic: the server never runs layout effects, and the browser runs this one before the first paint\nconst useBeforePaint = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/** A headline whose words rise into place once, the first time it scrolls into view. Restrained by design: a short\n *  stagger, no repeat. When the text later changes (a figure updates), only the words that changed rise again, so the\n *  eye goes to what is new. Reduced motion shows it at once. Rendered on the server, the words are in the page's HTML\n *  and show before any script: a headline already in view when the page comes alive stays as it is (rising then would\n *  hide what was read), one further down waits out of sight and rises when it is reached. */\nexport function TextReveal({\n  text,\n  as: Tag = \"h2\",\n  delay = 0,\n  className,\n  classNames,\n  style,\n  ref,\n  id,\n}: TextRevealProps) {\n  const host = useRef<HTMLHeadingElement>(null);\n  const attach = useComposedRef(host, ref);\n  const tokens = useMotionTokens(host);\n  const managedReduced = useReducedMotionSafe();\n  const reduced = tokens.reduced || managedReduced;\n  // false while the server renders and while the browser takes over server HTML, true on a render made in the browser\n  const browser = useSyncExternalStore(\n    never,\n    () => true,\n    () => false,\n  );\n  const [served] = useState(!browser);\n  // held: the words wait, hidden, for the headline to come into view. A headline made in the browser is held from its\n  // first render (nothing has been painted yet); server HTML is shown until the browser has looked where it stands.\n  const [held, setHeld] = useState(!served);\n  const [seen, setSeen] = useState(false);\n  const [completed, setCompleted] = useState(false);\n  useBeforePaint(() => {\n    if (!served || reduced) return;\n    const el = host.current;\n    const box = el?.getBoundingClientRect();\n    if (box && (box.top >= window.innerHeight || box.bottom <= 0)) setHeld(true);\n    else setCompleted(true);\n  }, []); // once, as the page comes alive\n  useEffect(() => {\n    if (reduced) {\n      setSeen(true);\n      setCompleted(true);\n      return;\n    }\n    const el = host.current;\n    if (!el || !held) return;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        if (e.isIntersecting) {\n          setSeen(true);\n          io.disconnect();\n        }\n      },\n      { threshold: 0.4 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [reduced, held]);\n  const words = text.split(\" \");\n  // after the first reveal, a changed text replays only the words that differ from what stood in their place\n  const [shown, setShown] = useState(text);\n  const [changed, setChanged] = useState<Set<number> | null>(null);\n  if (text !== shown) {\n    const old = shown.split(\" \");\n    setShown(text);\n    setChanged(new Set(words.flatMap((w, i) => (old[i] === w ? [] : [i]))));\n  }\n  const first = changed ? Math.min(...changed) : 0;\n  return (\n    <Tag\n      ref={attach}\n      id={id}\n      data-slot=\"text-reveal\"\n      className={cn(styles.root, classNames?.root, className)}\n      style={style}\n    >\n      {words.map((w, i) => (\n        <span\n          key={`${i}-${w}`}\n          data-slot=\"text-reveal-word\"\n          className={cn(\n            \"inline-block whitespace-pre\",\n            reduced\n              ? \"opacity-100\"\n              : completed\n                ? changed?.has(i) && \"animate-enter-rise\"\n                : seen\n                  ? \"animate-enter-rise\"\n                  : held && \"opacity-0 motion-reduce:opacity-100\",\n            classNames?.word,\n          )}\n          style={{\n            animationDelay: completed\n              ? `calc(${i - first} * var(--pui-stagger-word))`\n              : `calc(${delay}ms * var(--pui-motion-scale, 1) * var(--pui-motion-look, 1) + ${i} * var(--pui-stagger-word))`,\n            animationDuration: \"var(--pui-duration-considered)\",\n          }}\n          onAnimationEnd={i === words.length - 1 ? () => setCompleted(true) : undefined}\n        >\n          {w}\n          {i < words.length - 1 ? \" \" : \"\"}\n        </span>\n      ))}\n    </Tag>\n  );\n}\n\nexport default TextReveal;\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/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 reveal is in src/ui/beamline/components/text-reveal/text-reveal.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-reveal"}}