{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-roll","type":"registry:ui","title":"TextRoll","description":"Single-line text that rolls to its new value per grapheme, odometer-style, whenever the value prop changes — while the real, unsplit text stays selectable and readable","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["text"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/text-roll/TextRoll.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties, HTMLAttributes, RefCallback } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { runTransition, type TransitionRun } from \"../../internals/motion/animate\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { DURATIONS, STAGGERS, STAGGER_CAPS } from \"../../internals/motion/tokens\";\nimport type { StaggerFrom } from \"../../internals/motion/types\";\nimport { rollIn, rollOut, type RollTransitionParams } from \"./roll-transitions\";\nimport \"./text-roll.css\";\n\nexport interface TextRollProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n\t/**\n\t * The text to display. A change after mount triggers a per-grapheme\n\t * roll to the new value. TextRoll has no `trigger` prop of its own\n\t * (unlike Reveal) — it only ever reacts to `value` changing, so the\n\t * very first render is always a plain, non-animated real-layer text\n\t * node. That is what keeps it safe from Presence's documented \"no\n\t * intro on first mount\" trap without any special-casing here.\n\t */\n\tvalue: string;\n\t/**\n\t * Which way the cells travel. `\"auto\"` compares the trimmed old and\n\t * new values as numbers and rolls up for an increase, down for a\n\t * decrease; a non-numeric change, a tie, or an empty side falls back\n\t * to `\"up\"`.\n\t */\n\tdirection?: \"auto\" | \"up\" | \"down\";\n\t/** Roll duration in ms. Collapsed to `0` under\n\t * `prefers-reduced-motion: reduce` (synchronous swap). */\n\tduration?: number;\n\t/** Per-cell stagger step in ms, before compression — see\n\t * `STAGGER_CAPS.text` (200ms total). Collapsed to `0` under reduced\n\t * motion, same as `duration`. */\n\tstagger?: number;\n\t/** Stagger origin, forwarded to the shared `staggerDelay` helper. */\n\tfrom?: StaggerFrom;\n\t/** `font-variant-numeric: tabular-nums` on BOTH layers (must be both,\n\t * or the two layers stop being the same width) — locks digit advance\n\t * width, primarily useful alongside a numeric `direction=\"auto\"`\n\t * ticker. Does not fix general alignment risk for non-digit text. */\n\ttabular?: boolean;\n\t/**\n\t * Off by default: a live region firing on every tick of a fast\n\t * counter is worse than silence. `\"polite\"`/`\"assertive\"` put the\n\t * matching `role` + `aria-live` pair on the real (unsplit) text layer\n\t * only — the cell layer stays `aria-hidden` in every state, so\n\t * assistive tech never gets fragmented per-grapheme spam.\n\t */\n\tlive?: \"off\" | \"polite\" | \"assertive\";\n}\n\n// The prop default IS the token, not a second hardcoded `300` that could\n// drift from it — `tokens.ts` is the one place these numbers live (see\n// its own header comment).\nconst DEFAULT_DURATION = DURATIONS.base;\n\n/**\n * Grapheme splitting. `Intl.Segmenter` groups combining marks and\n * ZWJ-joined emoji into ONE cluster each (`Array.from`/code-unit\n * splitting does not — it breaks \"café\"'s composed `é` into `e` + a\n * detached accent, and shreds a ZWJ family emoji into seven broken\n * pieces); when the API is unavailable the string is not split at all —\n * one cell, the whole value crossfades as a unit — never a code-unit\n * fallback that would silently corrupt those cases. The\n * `typeof Intl.Segmenter` probe lives INSIDE the function and nothing is\n * cached at module scope, so a test that deletes `Intl.Segmenter` and\n * mounts a fresh instance sees the fallback take effect immediately.\n */\nfunction segmentGraphemes(text: string): string[] {\n\tif (typeof Intl.Segmenter !== \"function\") return [text];\n\tconst segmenter = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\n\tconst graphemes: string[] = [];\n\tfor (const { segment } of segmenter.segment(text)) graphemes.push(segment);\n\treturn graphemes;\n}\n\n/**\n * `direction=\"auto\"` resolution. Guards the classic `Number(\"\") === 0`\n * footgun explicitly: an empty trimmed side never resolves through the\n * numeric branch, so clearing a field never reads as \"counted down to\n * nothing\".\n */\nfunction resolveDirection(\n\tprev: string,\n\tnext: string,\n\trequested: \"auto\" | \"up\" | \"down\"\n): \"up\" | \"down\" {\n\tif (requested !== \"auto\") return requested;\n\tconst p = prev.trim();\n\tconst n = next.trim();\n\tif (p !== \"\" && n !== \"\") {\n\t\tconst pn = Number(p);\n\t\tconst nn = Number(n);\n\t\tif (Number.isFinite(pn) && Number.isFinite(nn) && pn !== nn) return nn > pn ? \"up\" : \"down\";\n\t}\n\treturn \"up\";\n}\n\n/**\n * One overlay-layer cell as actually rendered. The Svelte source leans on\n * its keyed `{#each}` plus `in:`/`out:` directives to keep a leaving cell in\n * the DOM until its outro finishes; React removes a dropped child in the\n * same commit, so the leaving cells have to be owned explicitly — `phase`\n * is that ownership. `\"idle\"` is a first-mount cell (never animated,\n * mirroring \"no intro on first mount\"), `\"enter\"` runs `rollIn`, `\"exit\"`\n * runs `rollOut` and is filtered out of the list when it lands.\n */\ninterface RenderCell {\n\tkey: string;\n\tgrapheme: string;\n\t/** Frozen at the render that created the cell — a cell being REMOVED\n\t * keeps the index it actually held (see `RollTransitionParams.index`). */\n\tindex: number;\n\tphase: \"idle\" | \"enter\" | \"exit\";\n}\n\ninterface CurrentCell {\n\tkey: string;\n\tgrapheme: string;\n\tindex: number;\n}\n\n/**\n * The React spelling of Svelte's keyed-each reconciliation against the\n * `${generation}:${index}:${grapheme}` keys: an unchanged key keeps its\n * object (and therefore its DOM node), a new key enters, a dropped key is\n * kept in the list but flipped to `\"exit\"` so its node survives long enough\n * to roll out. A key that comes BACK while its old cell is still exiting is\n * resurrected as `\"enter\"` — the reversal case the sampler's counterpart\n * mechanism smooths from the in-flight position.\n */\nfunction mergeCells(current: CurrentCell[], prev: RenderCell[]): RenderCell[] {\n\tconst currentKeys = new Set(current.map((c) => c.key));\n\tconst prevByKey = new Map(prev.map((c) => [c.key, c]));\n\n\tconst next: RenderCell[] = current.map((c) => {\n\t\tconst existing = prevByKey.get(c.key);\n\t\tif (!existing) return { ...c, phase: \"enter\" as const };\n\t\tif (existing.phase === \"exit\") return { ...existing, phase: \"enter\" as const };\n\t\treturn existing;\n\t});\n\tfor (const p of prev) {\n\t\tif (currentKeys.has(p.key)) continue;\n\t\tnext.push(p.phase === \"exit\" ? p : { ...p, phase: \"exit\" as const });\n\t}\n\treturn next;\n}\n\n/** One cell's seat on the roll: the leg it last started (`to`) and, while\n * that leg is still in flight, its run — kept as the counterpart a\n * reversing leg reads its start position from. */\ninterface CellLeg {\n\trun: TransitionRun | undefined;\n\tto: 0 | 1 | null;\n}\n\nexport const TextRoll = forwardRef<HTMLSpanElement, TextRollProps>(function TextRoll(\n\t{\n\t\tvalue,\n\t\tdirection = \"auto\",\n\t\tduration = DEFAULT_DURATION,\n\t\t// Same rationale as `DEFAULT_DURATION` above: `STAGGERS.char` IS\n\t\t// TextRoll's per-grapheme step (that key's own comment in `tokens.ts`\n\t\t// says so) — not a second `15` that could silently drift from it.\n\t\tstagger = STAGGERS.char,\n\t\tfrom = \"first\",\n\t\ttabular = false,\n\t\tlive = \"off\",\n\t\tclassName,\n\t\tstyle,\n\t\t...restProps\n\t},\n\tref\n) {\n\tconst reduced = useReducedMotion();\n\n\t// Reduced motion collapses BOTH knobs. `effDuration` is what actually\n\t// disables the roll: the transition sampler (`runTransition`)\n\t// short-circuits synchronously BEFORE it ever reads `delay` or calls\n\t// `element.animate` when `duration` is `0`, so a non-zero stagger next to\n\t// a zero duration could never produce a staggered sequence of its own.\n\t// Collapsing `effStagger` too is defensive belt-and-braces — correct, and\n\t// keeps this component honest if that short-circuit ever changes — not\n\t// the mechanism that makes reduced motion synchronous.\n\tconst effDuration = reduced ? 0 : duration;\n\tconst effStagger = reduced ? 0 : stagger;\n\n\t// Cached on `value` alone, the source's `$derived(segmentGraphemes(value))`\n\t// dependency exactly. It matters more here than there: a roll re-renders\n\t// this component once per settled cell leg (each WAAPI `onfinish` lands\n\t// `endTransition` in its own task, so those updates are not batched), and\n\t// an uncached call would build a fresh `Intl.Segmenter` and re-walk the\n\t// whole string on every one of them, inside the animation window.\n\tconst graphemes = useMemo(() => segmentGraphemes(value), [value]);\n\n\t// `generation` is the mechanism behind \"same length ⇒ minimal diff,\n\t// different length ⇒ safe full re-roll\": every cell's key is prefixed\n\t// with it, so leaving it unchanged lets an untouched `${index}:${grapheme}`\n\t// position keep its DOM node (keyed reconciliation IS the diff — no\n\t// custom algorithm needed), while bumping it forces EVERY key to change\n\t// at once, even ones that would otherwise coincidentally still match.\n\t// That second case matters because a positional-alignment heuristic\n\t// across a length change is guessable but wrong in general (a\n\t// trailing-zero append like \"4.5\"→\"4.50\" would break a right-edge\n\t// heuristic badly) — a full reroll is the honest fallback. Bumped in the\n\t// same post-commit effect that arms the backstop, mirroring the source's\n\t// after-DOM-update effect timing.\n\tconst [generation, setGeneration] = useState(0);\n\tconst [resolvedDirection, setResolvedDirection] = useState<\"up\" | \"down\">(() =>\n\t\tdirection === \"auto\" ? \"up\" : direction\n\t);\n\tconst [inFlight, setInFlight] = useState(0);\n\tconst rollState = inFlight > 0 ? \"rolling\" : \"idle\";\n\n\t// Same caching rationale as `graphemes` above, and the same dependencies\n\t// the source's `$derived` has: the key list can only move when the\n\t// graphemes or the generation move, never because a leg settled.\n\tconst cells: CurrentCell[] = useMemo(\n\t\t() =>\n\t\t\tgraphemes.map((grapheme, index) => ({\n\t\t\t\tkey: `${generation}:${index}:${grapheme}`,\n\t\t\t\tgrapheme,\n\t\t\t\tindex,\n\t\t\t})),\n\t\t[graphemes, generation]\n\t);\n\n\t// Derived-during-render state: the rendered list is re-merged\n\t// synchronously in the render whose keys changed, BEFORE React commits —\n\t// the only moment a dropped cell can still be caught and kept as\n\t// `\"exit\"` instead of being unmounted with its outro unplayed.\n\tconst [rendered, setRendered] = useState<RenderCell[]>(() =>\n\t\tcells.map((c) => ({ ...c, phase: \"idle\" as const }))\n\t);\n\tconst keysSignature = useMemo(() => cells.map((c) => c.key).join(\"\\u0000\"), [cells]);\n\tconst [prevKeysSignature, setPrevKeysSignature] = useState(keysSignature);\n\tif (keysSignature !== prevKeysSignature) {\n\t\tsetPrevKeysSignature(keysSignature);\n\t\tsetRendered((prev) => mergeCells(cells, prev));\n\t}\n\n\t// Live mirrors for the values a leg's params read at the instant it\n\t// starts — never captured per render by the orchestrator, so a\n\t// `duration`-only change can neither restart legs nor retime ones\n\t// already in flight (the source's `untrack` on `effDuration`).\n\tconst effDurationRef = useLiveRef(effDuration);\n\tconst effStaggerRef = useLiveRef(effStagger);\n\tconst fromRef = useLiveRef(from);\n\tconst countRef = useLiveRef(graphemes.length);\n\n\tconst nodesRef = useRef(new Map<string, HTMLSpanElement>());\n\tconst legsRef = useRef(new Map<string, CellLeg>());\n\tconst refCallbacksRef = useRef(new Map<string, RefCallback<HTMLSpanElement>>());\n\t// Written by the direction effect below (which runs before the\n\t// orchestrator — declaration order is execution order), so the legs a\n\t// value change starts read the direction resolved from THAT change.\n\tconst dirRef = useRef(resolvedDirection);\n\n\t/** Cached for the life of the cell, so React never detaches and\n\t * reattaches a node just because the component re-rendered. */\n\tfunction getCellRef(key: string): RefCallback<HTMLSpanElement> {\n\t\tlet callback = refCallbacksRef.current.get(key);\n\t\tif (!callback) {\n\t\t\t// Block body, never a concise arrow: React 19 reads a returned\n\t\t\t// value as a cleanup function (convention C-3).\n\t\t\tcallback = (node) => {\n\t\t\t\tif (node) {\n\t\t\t\t\tnodesRef.current.set(key, node);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// The node is leaving the DOM in this very commit; the\n\t\t\t\t// animation goes with it, so drop it rather than leaving a\n\t\t\t\t// Chromium effect leak behind.\n\t\t\t\tnodesRef.current.delete(key);\n\t\t\t\tconst leg = legsRef.current.get(key);\n\t\t\t\tleg?.run?.abort();\n\t\t\t\tlegsRef.current.delete(key);\n\t\t\t\t// Evict by identity: a newer callback for a key that came back\n\t\t\t\t// mid-exit must not be dropped by the old one's detach.\n\t\t\t\tif (refCallbacksRef.current.get(key) === callback) {\n\t\t\t\t\trefCallbacksRef.current.delete(key);\n\t\t\t\t}\n\t\t\t};\n\t\t\trefCallbacksRef.current.set(key, callback);\n\t\t}\n\t\treturn callback;\n\t}\n\n\tfunction beginTransition() {\n\t\tsetInFlight((n) => n + 1);\n\t}\n\tfunction endTransition() {\n\t\tsetInFlight((n) => Math.max(0, n - 1));\n\t}\n\n\tfunction startLeg(cell: RenderCell, to: 0 | 1): void {\n\t\tconst node = nodesRef.current.get(cell.key);\n\t\tif (!node) return;\n\n\t\tconst leg = legsRef.current.get(cell.key) ?? { run: undefined, to: null };\n\t\tconst params: RollTransitionParams = {\n\t\t\tdirection: dirRef.current,\n\t\t\tduration: effDurationRef.current,\n\t\t\tindex: cell.index,\n\t\t\tcount: countRef.current,\n\t\t\tstep: effStaggerRef.current,\n\t\t\tfrom: fromRef.current,\n\t\t};\n\t\tconst spec = to === 1 ? rollIn(node, params) : rollOut(node, params);\n\n\t\t// Read BEFORE the bookkeeping below overwrites it: the in-flight leg\n\t\t// is this one's counterpart, and `runTransition` reads its position\n\t\t// before aborting it — a resurrection mid-exit resumes from where the\n\t\t// exit actually got to. A superseded leg's own finish callback is\n\t\t// deactivated by `runTransition`, so its `endTransition` never fires\n\t\t// — exactly the \"interrupted transition may never fire its end event\"\n\t\t// case the hard timeout backstop below exists to recover from.\n\t\tconst counterpart = leg.run;\n\t\tleg.to = to;\n\t\tleg.run = undefined;\n\t\tlegsRef.current.set(cell.key, leg);\n\n\t\tbeginTransition();\n\t\tlet finished = false;\n\t\tconst handle: { current: TransitionRun | undefined } = { current: undefined };\n\t\tconst run = runTransition(node, spec, to, counterpart, () => {\n\t\t\tfinished = true;\n\t\t\tendTransition();\n\t\t\tif (to === 1) {\n\t\t\t\t// On ENTER finish, abort: that removes `fill: forwards` so the\n\t\t\t\t// cell falls back to its resting style (the visible end state\n\t\t\t\t// by construction). On EXIT finish, deliberately do NOT — the\n\t\t\t\t// node stays in the DOM until React processes the filtered\n\t\t\t\t// list, and dropping fill-forwards would flash it back for a\n\t\t\t\t// frame.\n\t\t\t\thandle.current?.abort();\n\t\t\t\tleg.run = undefined;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlegsRef.current.delete(cell.key);\n\t\t\tsetRendered((prev) => prev.filter((p) => p.key !== cell.key));\n\t\t});\n\t\thandle.current = run;\n\t\t// A duration-0 leg already finished synchronously inside the call\n\t\t// above; keeping its handle would hand a settled leg to the next one\n\t\t// as a counterpart.\n\t\tif (!finished) leg.run = run;\n\t}\n\n\t// Two SEPARATE effects for direction and for the generation/backstop\n\t// bookkeeping, not one — deliberately, transposed from the source:\n\t// `resolvedDirection` must react to `direction` alone (an explicit prop\n\t// flip must update `data-direction` even with no new `value`), while the\n\t// backstop effect must NEVER re-run on a `direction`-only change — its\n\t// cleanup tears the armed timer down before the new body runs, so an\n\t// early-return guard inside the body could not protect it. Keeping\n\t// `direction` (and `duration`) out of that effect's dependency array is\n\t// what actually keeps the timer intact.\n\n\t// Effect 1: `resolvedDirection`. No cleanup of its own, so re-running it\n\t// on every `value` OR `direction` change is inert. Layout phase, and\n\t// declared BEFORE the orchestrator, so the legs started for this very\n\t// value change already read the freshly resolved direction.\n\tconst priorValueForDirectionRef = useRef(value);\n\tconst priorRequestedDirectionRef = useRef(direction);\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst nextValue = value;\n\t\tconst requested = direction;\n\t\t// Keyed on the value/direction EDGE, not on \"first run\": the seed run and\n\t\t// a StrictMode replay both see the values the refs were seeded with and\n\t\t// do nothing, so a double-invoked mount cannot resolve against a stale\n\t\t// (or missing) prior value.\n\t\tif (\n\t\t\tpriorValueForDirectionRef.current === nextValue &&\n\t\t\tpriorRequestedDirectionRef.current === requested\n\t\t) {\n\t\t\treturn;\n\t\t}\n\t\tconst resolved = resolveDirection(\n\t\t\tpriorValueForDirectionRef.current ?? \"\",\n\t\t\tnextValue ?? \"\",\n\t\t\trequested\n\t\t);\n\t\tpriorValueForDirectionRef.current = nextValue;\n\t\tpriorRequestedDirectionRef.current = requested;\n\t\tdirRef.current = resolved;\n\t\tsetResolvedDirection(resolved);\n\t}, [value, direction]);\n\n\t// The leg orchestrator: starts exactly the legs whose desired direction\n\t// differs from the one their cell last started — everything else is a\n\t// no-op, so re-running on every `rendered` change is safe. Layout phase\n\t// (contract §4): a transition leg's absence is visible in the first\n\t// painted frame. NO cleanup here — a cleanup would abort every in-flight\n\t// leg on each re-render, which is the one thing the per-key `CellLeg`\n\t// bookkeeping exists to avoid; teardown lives in the unmount-only effect\n\t// below and in each cell's ref cleanup.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tfor (const cell of rendered) {\n\t\t\tconst desired: 0 | 1 | null =\n\t\t\t\tcell.phase === \"exit\" ? 0 : cell.phase === \"enter\" ? 1 : null;\n\t\t\tif (desired === null) continue;\n\t\t\tconst leg = legsRef.current.get(cell.key);\n\t\t\tif (leg && leg.to === desired) continue;\n\t\t\tstartLeg(cell, desired);\n\t\t}\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [rendered]);\n\n\t// Effect 2: generation bump + the hard timeout backstop. Depends ONLY on\n\t// `value` — never `direction`, and never `effDuration` (read through its\n\t// live ref, the source's `untrack`): re-arming or re-sizing the window\n\t// mid-roll would let a `duration`-only change end `inFlight` at the\n\t// 600ms floor and expose the real layer under still-animating cells. The\n\t// window is sized once, from the duration the roll actually started\n\t// with. `firstRun` skips the bookkeeping on mount — there is no prior\n\t// value to diff against yet, and skipping it also means the backstop\n\t// timer is never armed for content that never rolled.\n\tconst priorLengthRef = useRef(graphemes.length);\n\tconst backstopFirstRunRef = useRef(true);\n\tuseEffect(() => {\n\t\tconst nextLength = graphemes.length;\n\n\t\tif (backstopFirstRunRef.current) {\n\t\t\tbackstopFirstRunRef.current = false;\n\t\t\tpriorLengthRef.current = nextLength;\n\t\t\treturn;\n\t\t}\n\n\t\tif (nextLength !== priorLengthRef.current) setGeneration((g) => g + 1);\n\t\tpriorLengthRef.current = nextLength;\n\n\t\t// Unconditional backstop: whatever happens to the per-cell leg\n\t\t// bookkeeping above — an interrupted transition, dropped by ANOTHER\n\t\t// value arriving mid-roll, may never fire its end callback — this\n\t\t// roll is guaranteed to read \"idle\" again. `DURATIONS.entrance`\n\t\t// (600ms) is the floor this contract names; a longer `duration` plus\n\t\t// the worst-case stagger spread (`STAGGER_CAPS.text`) can\n\t\t// legitimately run past that floor, so the budget grows past 600ms\n\t\t// rather than silently truncating a caller's own `duration`.\n\t\t// `DURATIONS.fast` is slack for the delay-then-animate double\n\t\t// `element.animate()` call the sampler makes. The returned cleanup\n\t\t// clears this timer both on the NEXT value change (re-arming a fresh\n\t\t// window rather than trusting whatever was left on the old one) and\n\t\t// on unmount.\n\t\tconst backstopMs = Math.max(\n\t\t\tDURATIONS.entrance,\n\t\t\teffDurationRef.current + STAGGER_CAPS.text + DURATIONS.fast\n\t\t);\n\t\tconst timeoutId = setTimeout(() => {\n\t\t\tsetInFlight(0);\n\t\t}, backstopMs);\n\n\t\treturn () => clearTimeout(timeoutId);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [value]);\n\n\t// Unmount-only teardown: abort whatever is still in flight. Each cell's\n\t// ref cleanup already does this per node as React detaches it; this is\n\t// the belt for the pair of braces.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst legs = legsRef.current;\n\t\treturn () => {\n\t\t\tfor (const leg of legs.values()) leg.run?.abort();\n\t\t\tlegs.clear();\n\t\t};\n\t}, []);\n\n\tconst liveRole = live === \"off\" ? undefined : live === \"polite\" ? \"status\" : \"alert\";\n\tconst liveAriaLive = live === \"off\" ? undefined : live;\n\n\t// Conditional inline var write — see `DEFAULT_DURATION` above: an inline\n\t// style always wins over a stylesheet, so writing it unconditionally\n\t// would make the CSS fallback chain in text-roll.css unreachable. Vars\n\t// land AFTER the caller's `style` so they win, matching the source's\n\t// style: directive beating the style attribute.\n\tconst vars: Record<string, string> = {};\n\tif (duration !== DEFAULT_DURATION) vars[\"--ft-textroll-duration\"] = `${duration}ms`;\n\n\tconst layerStyle: CSSProperties | undefined = tabular\n\t\t? { fontVariantNumeric: \"tabular-nums\" }\n\t\t: undefined;\n\n\treturn (\n\t\t<span\n\t\t\t{...restProps}\n\t\t\tref={ref}\n\t\t\tclassName={cn(\"ft-textroll\", className)}\n\t\t\tstyle={{ ...style, ...vars } as CSSProperties}\n\t\t\tdata-state={rollState}\n\t\t\tdata-direction={resolvedDirection}\n\t\t>\n\t\t\t<span className=\"ft-textroll-real\" style={layerStyle} role={liveRole} aria-live={liveAriaLive}>\n\t\t\t\t{value}\n\t\t\t</span>\n\t\t\t<span className=\"ft-textroll-cells\" aria-hidden=\"true\" style={layerStyle}>\n\t\t\t\t{rendered.map((cell) => (\n\t\t\t\t\t<span\n\t\t\t\t\t\tkey={cell.key}\n\t\t\t\t\t\tref={getCellRef(cell.key)}\n\t\t\t\t\t\tclassName=\"ft-textroll-cell\"\n\t\t\t\t\t\tstyle={{ gridColumnStart: cell.index + 1 }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{cell.grapheme}\n\t\t\t\t\t</span>\n\t\t\t\t))}\n\t\t\t</span>\n\t\t</span>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/text-roll/TextRoll.tsx"},{"path":"react/src/components/text-roll/index.ts","content":"\"use client\";\n\nexport { TextRoll } from \"./TextRoll\";\nexport type { TextRollProps } from \"./TextRoll\";\n","type":"registry:ui","target":"@ui/fancy/components/text-roll/index.ts"},{"path":"react/src/components/text-roll/roll-transitions.ts","content":"\"use client\";\n\n/**\n * `rollIn` / `rollOut` — the per-grapheme cell transitions TextRoll's overlay\n * layer runs on its enter/exit legs. Unlike `internals/motion/transitions.ts`'s\n * `preset()` (one shared geometry table shared by Reveal/Presence/\n * StickyScroll), these two are specific to TextRoll's own \"rolling cylinder\"\n * geometry and its own per-cell stagger bookkeeping, so they live next to the\n * component that uses them instead of in the shared foundation — the frozen\n * contract draws exactly this line (`text-roll/roll-transitions.ts`, not\n * `internals/motion/`), and the foundation exports nothing named `rollIn`/\n * `rollOut` for this file to wrap.\n *\n * Both are css-only transition specs (a `css(t, u)` string, no `tick`) — the\n * same WAAPI mechanism `preset()` uses, so the same fact its file header\n * documents applies unchanged here: the sampler (`animate.ts`'s\n * `runTransition`) samples `t`/`u` from the spec's OWN easing curve before\n * `css()` ever runs, so `easing` only has to be handed through on the\n * returned `TransitionSpec`, never applied a second time inside `cssForRoll`.\n */\n\nimport type { TransitionSpec } from \"../../internals/motion/transitions\";\nimport { DURATIONS, JS_EASINGS, STAGGER_CAPS } from \"../../internals/motion/tokens\";\nimport { staggerDelay, type StaggerFrom } from \"../../internals/motion/stagger\";\n\nexport interface RollTransitionParams {\n\t/**\n\t * \"up\" mirrors an odometer counting up: an entering cell rises from\n\t * below into place, a leaving cell continues rising off the top. \"down\"\n\t * is the mirror image. The enter (`rollIn`) and exit (`rollOut`) legs on\n\t * the SAME roll always receive the same `direction` — a same-length\n\t * update at one position runs its exit and entrance as one coherent\n\t * turn, not two independently-decided fades.\n\t */\n\tdirection: \"up\" | \"down\";\n\t/** ms. `0` (reduced motion, written by the component) makes the\n\t * transition sampler short-circuit synchronously — nothing in this file\n\t * has to special-case that. */\n\tduration?: number;\n\t/**\n\t * This cell's own position, frozen into the cell object by TextRoll at\n\t * the render that created it — so a cell being REMOVED keeps the index\n\t * it actually held there rather than one recomputed against whatever\n\t * replaced it.\n\t */\n\tindex: number;\n\t/**\n\t * The CURRENT (new) value's grapheme count — deliberately the SAME\n\t * number for every cell in one roll, entering and leaving alike, so a\n\t * `from=\"center\"` sweep reads as one sweep across the new shape rather\n\t * than two sweeps computed against two different lengths (only possible\n\t * to observe on a length-changing update, where old and new counts\n\t * differ; on the far more common same-length update they are already\n\t * identical).\n\t */\n\tcount: number;\n\t/** The `stagger` prop's per-step ms — already collapsed to `0` under\n\t * reduced motion by the component. This file applies `STAGGER_CAPS.text`\n\t * compression on top, via the shared `staggerDelay` helper (compression,\n\t * never per-cell clipping — see that file). */\n\tstep: number;\n\t/** Stagger origin, forwarded from the `from` prop. Omitted, `staggerDelay`\n\t * falls back to its own `\"first\"` default. */\n\tfrom?: StaggerFrom;\n}\n\nfunction delayFor(params: RollTransitionParams): number {\n\treturn staggerDelay(params.index, params.count, params.step, params.from, STAGGER_CAPS.text);\n}\n\n/**\n * Both directions share one geometry: the hidden-state offset is\n * `sign * var(--ft-textroll-distance, 1em) * u` — the same \"u scales the\n * hidden-state offset toward zero as the transition completes\" shape\n * `transitions.ts`'s `cssFor` uses for Reveal/Presence's directional\n * presets. `rollIn`/`rollOut` differ only in which `sign` they pass.\n * `--ft-textroll-distance` is read here, not written by the component (no\n * `distance` prop exists — see the Svelte README) — a consumer overrides\n * travel by setting this var on `.ft-textroll` from their own CSS.\n */\nfunction cssForRoll(sign: 1 | -1, t: number, u: number): string {\n\treturn `opacity: ${t}; transform: translateY(calc(${sign} * var(--ft-textroll-distance, 1em) * ${u}))`;\n}\n\n/**\n * A cell arriving at its slot. `direction=\"up\"`: rises from below\n * (`+distance`) into place. `direction=\"down\"`: descends from above\n * (`-distance`) into place.\n */\nexport function rollIn(_node: Element, params: RollTransitionParams): TransitionSpec {\n\tconst sign = params.direction === \"up\" ? 1 : -1;\n\treturn {\n\t\tduration: params.duration ?? DURATIONS.base,\n\t\tdelay: delayFor(params),\n\t\teasing: JS_EASINGS.out,\n\t\tcss: (t, u) => cssForRoll(sign, t, u),\n\t};\n}\n\n/**\n * A cell leaving its slot, continuing the SAME rotation the entering cell\n * (`rollIn`, above) is completing: `direction=\"up\"` exits upward (toward\n * `-distance`), `direction=\"down\"` exits downward — the OPPOSITE sign from\n * `rollIn`'s for the same `direction`, which is exactly what makes the two\n * read as one wheel turning rather than two unrelated fades.\n */\nexport function rollOut(_node: Element, params: RollTransitionParams): TransitionSpec {\n\tconst sign = params.direction === \"up\" ? -1 : 1;\n\treturn {\n\t\tduration: params.duration ?? DURATIONS.base,\n\t\tdelay: delayFor(params),\n\t\teasing: JS_EASINGS.in,\n\t\tcss: (t, u) => cssForRoll(sign, t, u),\n\t};\n}\n","type":"registry:ui","target":"@ui/fancy/components/text-roll/roll-transitions.ts"},{"path":"react/src/components/text-roll/text-roll.css","content":"/*\n * `inline-grid` stacks both layers on the SAME grid area, so the\n * implicit track sizes to `max-content` across both — the box is exactly\n * as wide as the wider layer, no manual width-matching CSS needed,\n * provided both layers shape identically (see the Svelte README's\n * \"supported scope\" section for where that assumption breaks and why it\n * degrades safely). `white-space: pre` is set ONCE here — it is inherited,\n * so both children get it without repeating the declaration, and critically\n * both layers then collapse whitespace by the IDENTICAL rule: a space\n * grapheme rendered in its own isolated cell (see `.ft-textroll-cell`\n * below) is at the start AND end of that cell's own inline formatting\n * context, so under `nowrap` (which still collapses) it would be trimmed\n * to zero width, while the real layer's single text node collapses the\n * same run of whitespace consistently — `pre` (preserve) is what keeps\n * both layers agreeing on width for any number of consecutive spaces.\n * `pre` still implies no-wrap (single line, unchanged), but it DOES\n * preserve a literal `\\n` as a forced break — `value` must not contain\n * newlines (see the Svelte README's \"Supported scope\").\n */\n.ft-textroll {\n\tdisplay: inline-grid;\n\twhite-space: pre;\n\t/* tokens.DURATIONS.base — this var is NOT read by anything in this\n\t   file: the roll itself is JS/WAAPI-driven (roll-transitions.ts reads\n\t   `duration` as a plain number, not this CSS var). It exists purely\n\t   as a public theming/introspection hook, matching the family-wide\n\t   `--ft-<compact>-duration` convention, for a consumer's own CSS that\n\t   wants to stay in sync with the configured duration. */\n\t--ft-textroll-duration: var(--ft-duration-base, 300ms);\n}\n\n.ft-textroll .ft-textroll-real {\n\tgrid-area: 1 / 1;\n}\n\n/* aria-hidden, inert-by-CSS: never selectable, never hit-testable, and\n   invisible at rest — only the real layer above is authoritative except\n   for the roll's own brief window (see [data-state=\"rolling\"] below).\n   A NESTED grid, not plain flow: `.ft-textroll-cell` pins BOTH its\n   column (via `grid-column-start`, by index) AND its row (`grid-row: 1`,\n   below) explicitly, so a leaving and an entering cell for the SAME\n   grapheme position are fully co-located and deliberately overlap in one\n   grid cell instead of sitting side by side in normal flow for the whole\n   roll window. The latter is what used to widen this layer's box — and\n   the root's `max-content` track with it — on every value change: an\n   exit-transitioning element stays in the DOM, in flow, until its outro\n   completes, so the departing and arriving glyph were both present as\n   ordinary flow siblings. `align-items: baseline` keeps the row aligned\n   now that cells are blockified grid items rather than inline-block\n   siblings on one text line. */\n.ft-textroll .ft-textroll-cells {\n\tgrid-area: 1 / 1;\n\tdisplay: grid;\n\tgrid-auto-flow: column;\n\tgrid-auto-columns: max-content;\n\talign-items: baseline;\n\tjustify-content: start;\n\tuser-select: none;\n\tpointer-events: none;\n\topacity: 0;\n}\n\n/* `overflow: hidden` does NOT make a cell's `translateY` read as\n   \"rolling into/out of a slot\" — the transition is applied to the cell\n   itself, so its clip box travels WITH the glyph and there is nothing\n   left inside the cell to mask against a static slot. Kept per contract\n   anyway, for what it actually buys: it clips glyph overhang at the\n   transform's extremes, and it is what makes an inline-block synthesize\n   its baseline from its bottom margin edge rather than the glyph's own\n   text baseline. `grid-row: 1` pins every cell to the single row that\n   exists (see `.ft-textroll-cells` above) so that two cells sharing one\n   `grid-column-start` — set inline, per cell, in the markup — are fully\n   explicit on BOTH axes and therefore intentionally overlap instead of\n   the grid's auto-placement pushing the second one to a row of its own\n   to avoid a collision it would otherwise assume was unintended. */\n.ft-textroll .ft-textroll-cell {\n\tdisplay: inline-block;\n\tgrid-row: 1;\n\toverflow: hidden;\n}\n\n/*\n * The layer swap itself — not an animation, a discrete state change — is\n * still gated behind `no-preference` on purpose: under reduced motion\n * the cells layer must never become visible at all (there is nothing to\n * roll), so the resting styles above stay in force unconditionally and\n * the real layer — always showing the current `value` — is what a\n * reduced-motion user sees update, instantly, with no transparent frame\n * in between.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-textroll[data-state=\"rolling\"] .ft-textroll-real {\n\t\tcolor: transparent;\n\t}\n\n\t.ft-textroll[data-state=\"rolling\"] .ft-textroll-cells {\n\t\topacity: 1;\n\t}\n}\n\n/* Forced-colors mode (Windows High Contrast) forces `color` to a system\n   color on any element left at the default `forced-color-adjust: auto`\n   — only `opacity` and `background-color: transparent` pass through\n   untouched. That means the real layer's `color: transparent` above is\n   already going to be overridden by the UA during a roll (stating it\n   explicitly here as `CanvasText` is just naming that outcome rather\n   than leaving it to an implicit default). `opacity` is NOT forced,\n   though, so without the second rule below a high-contrast user would\n   see the cell layer's stale glyphs painted on top of the now-visible\n   real layer for the whole roll window — this degrades to the\n   reduced-motion outcome instead: an instant swap, cell layer never\n   shown. Same specificity as the `no-preference` block above, placed\n   after it, so source order alone decides without `!important`. */\n@media (forced-colors: active) {\n\t.ft-textroll[data-state=\"rolling\"] .ft-textroll-real {\n\t\tcolor: CanvasText;\n\t}\n\n\t.ft-textroll[data-state=\"rolling\"] .ft-textroll-cells {\n\t\topacity: 0;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/text-roll/text-roll.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/text-roll?framework=react"}}