{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-ticker",
  "type": "registry:ui",
  "title": "Number Ticker",
  "description": "An accessible React number ticker for Next.js and Tailwind CSS: digits roll like an odometer and glide as the number changes length, with Intl formatting.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/number-ticker.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { flow as timing } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  type CSSProperties,\n  useCallback,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Number Ticker: a number that flows to its value (owner's roadmap, 2026-10-16), for prices, stats and counters.\n * Light DOM, no dependencies (licence notice below).\n * - Each digit is a column of 0–9 placed by CSS: one number, the digit it shows, decides every row's offset\n *   through mod(), so only its neighbours are ever in view. A spin animates that one number (a registered custom\n *   property) from the old digit to the new, on a spring sampled into linear() (`flow` in our motion presets).\n * - The number fades at its edges, 0.25em above and below (digits spin out of sight) and 0.5em at the sides\n *   (characters come, go and glide), through our shipped `--size-roll-fade` tokens.\n * - When it changes length, the integer part holds to its right and the fraction to its left: each character\n *   that stays glides to its new place, and the number's width glides as a counter-scaled transform, so text\n *   beside it moves smoothly. New characters fade in, a new digit spinning up from 0; characters that go stay\n *   where they were and fade out, a leaving digit spinning down to 0. Changed signs cross-fade.\n * - Continuous (on by default): when a higher place changes, the unchanged places below\n *   it spin a whole turn, so 9 → 2,481 reads as counting up.\n * - It rolls from `from` once it's half in view (once), or on mount with startOnView={false). The server sends\n *   the final value, so it's indexed and hydrates without a mismatch.\n * - Screen readers get the formatted value: the number is an image named by it, so the moving parts are never read.\n * - With reduced motion, or a browser without CSS mod() and linear(), it changes without moving.\n */\n\n/*\n * Portions of this file are adapted from number-flow, © 2024 Maxwell Barvian, under the MIT licence:\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\n * documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation\n * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and\n * to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all copies or substantial portions of\n * the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO\n * THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF\n * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\n/** A part of the formatted number */\ntype Part = { type: string; value: number | string; key: string; pos?: number };\ntype Data = { pre: Part[]; integer: Part[]; fraction: Part[]; post: Part[]; label: string; value: number };\n\n/** How much of it shows before it rolls, so the roll isn't over before it's seen */\nconst IN_VIEW = 0.5;\n/** The digits a place shows */\nconst LENGTH = 10;\n/** The registered custom properties the animations run on */\nconst D_OPACITY = \"--_ticker-d-opacity\";\nconst D_WIDTH = \"--_ticker-d-width\";\nconst DX = \"--_ticker-dx\";\nconst D = \"--_ticker-d\";\n\n/** Half the fade above and below, on a whole pixel, pads each row; the fade itself is twice that */\nconst PAD = \"round(nearest, calc(var(--size-roll-fade) / 2), 1px)\";\nconst FADE_Y = `calc(${PAD} * 2)`;\nconst FADE_X = \"var(--size-roll-fade-x)\";\nconst FADE_X_SCALED = `calc(${FADE_X} / var(--scale-x))`; // check-tokens-ignore: the flow's own working variable, local to its stylesheet\nconst CORNER = \"#000 0, transparent 71%\"; // check-tokens-ignore: a mask works on alpha; #000 is opacity, never painted\n\n/** The flow's stylesheet, with our tokens. One copy for the page: React hoists and dedupes it. */\nconst CSS_TEXT = `[data-slot=number-ticker]{display:inline-block;direction:ltr;white-space:nowrap;isolation:isolate;line-height:1}\n.ticker-number,.ticker-number-inner{display:inline-block;transform-origin:left top}\n.ticker-number{--scale-x:calc(1 + var(${D_WIDTH}) / var(--width));transform:translateX(var(${DX})) scaleX(var(--scale-x));margin:0 calc(-1 * ${FADE_X});position:relative;-webkit-mask-image:linear-gradient(to right,transparent 0,#000 ${FADE_X_SCALED},#000 calc(100% - ${FADE_X_SCALED}),transparent),linear-gradient(to bottom,transparent 0,#000 ${FADE_Y},#000 calc(100% - ${FADE_Y}),transparent 100%),radial-gradient(at bottom right,${CORNER}),radial-gradient(at bottom left,${CORNER}),radial-gradient(at top left,${CORNER}),radial-gradient(at top right,${CORNER});-webkit-mask-size:100% calc(100% - ${FADE_Y} * 2),calc(100% - ${FADE_X_SCALED} * 2) 100%,${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y};-webkit-mask-position:center,center,top left,top right,bottom right,bottom left;-webkit-mask-repeat:no-repeat;mask-image:linear-gradient(to right,transparent 0,#000 ${FADE_X_SCALED},#000 calc(100% - ${FADE_X_SCALED}),transparent),linear-gradient(to bottom,transparent 0,#000 ${FADE_Y},#000 calc(100% - ${FADE_Y}),transparent 100%),radial-gradient(at bottom right,${CORNER}),radial-gradient(at bottom left,${CORNER}),radial-gradient(at top left,${CORNER}),radial-gradient(at top right,${CORNER});mask-size:100% calc(100% - ${FADE_Y} * 2),calc(100% - ${FADE_X_SCALED} * 2) 100%,${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y},${FADE_X_SCALED} ${FADE_Y};mask-position:center,center,top left,top right,bottom right,bottom left;mask-repeat:no-repeat}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */\n.ticker-number-inner{padding:${PAD} ${FADE_X};transform:scaleX(calc(1 / var(--scale-x))) translateX(calc(-1 * var(${DX})))}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */\n[data-slot=number-ticker] > :not(.ticker-number){z-index:5}\n.ticker-section,.ticker-symbol{display:inline-block;position:relative;isolation:isolate}\n.ticker-section::after{content:'\\\\200b';display:inline-block}\n.ticker-justify-left{transform-origin:center left}\n.ticker-justify-right{transform-origin:center right}\n.ticker-section > [inert],.ticker-symbol > [inert]{margin:0 !important;position:absolute !important;z-index:-1}\n.ticker-digit{display:inline-block;position:relative;--c:var(--current) + var(${D})}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */\n.ticker-digit-num,.ticker-number .ticker-section::after{padding:${PAD} 0}\n.ticker-digit-num{display:inline-block;--offset-raw:mod(var(--length) + var(--n) - mod(var(--c),var(--length)),var(--length));--offset:calc(var(--offset-raw) - var(--length) * round(down,var(--offset-raw) / (var(--length) / 2),1));--y:clamp(-100%,var(--offset) * 100%,100%);transform:translateY(var(--y))}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */\n.ticker-digit-num[inert]{position:absolute;top:0;left:50%;transform:translateX(-50%) translateY(var(--y))}/* check-tokens-ignore: the flow's own working variables, local to this stylesheet; #000 is mask alpha */\n.ticker-digit:not(.is-spinning) .ticker-digit-num[inert]{display:none}\n.ticker-symbol-value{display:inline-block;mix-blend-mode:plus-lighter;white-space:pre}\n.ticker-justify-left .ticker-symbol > [inert]{left:0}\n.ticker-justify-right .ticker-symbol > [inert]{right:0}\n.ticker-presence{opacity:calc(1 + var(${D_OPACITY}))}\n.ticker-static{display:inline-block}\n.ticker-static > span{display:inline-block;padding:${PAD} 0;white-space:pre}`;\n\n/** Whether this browser can run the flow: CSS mod(), linear() easing and registered custom properties */\nlet able: boolean | null = null;\nfunction canAnimate() {\n  if (able !== null) return able;\n  const modulo = typeof CSS !== \"undefined\" && !!CSS.supports?.(\"line-height\", \"mod(1,1)\");\n  let linear = false;\n  try {\n    document.createElement(\"div\").animate({ opacity: 0 }, { easing: \"linear(0, 1)\" });\n    linear = true;\n  } catch {\n    linear = false;\n  }\n  let registered = false;\n  try {\n    CSS.registerProperty({ name: D_OPACITY, syntax: \"<number>\", inherits: false, initialValue: \"0\" });\n    CSS.registerProperty({ name: DX, syntax: \"<length>\", inherits: true, initialValue: \"0px\" });\n    CSS.registerProperty({ name: D_WIDTH, syntax: \"<number>\", inherits: false, initialValue: \"0\" });\n    CSS.registerProperty({ name: D, syntax: \"<number>\", inherits: true, initialValue: \"0\" });\n    registered = true;\n  } catch {\n    // Already registered by another ticker on the page, or not supported\n    registered = typeof CSS !== \"undefined\" && typeof CSS.registerProperty === \"function\";\n  }\n  able = modulo && linear && registered;\n  return able;\n}\n\n/**\n * The formatted number in parts: what comes before it (a currency sign), the integer digits and\n * separators keyed from the right (so the ones stay the ones as it grows), the fraction keyed from the left, and\n * what comes after (a percent sign). Digits carry their place: 0 the ones, -1 the tenths.\n */\nfunction toData(value: number, formatter: Intl.NumberFormat, prefix?: string, suffix?: string): Data {\n  const parts: { type: string; value: string }[] = formatter.formatToParts(value);\n  if (prefix) parts.unshift({ type: \"prefix\", value: prefix });\n  if (suffix) parts.push({ type: \"suffix\", value: suffix });\n  const pre: Part[] = [];\n  const integers: Part[] = [];\n  const fraction: Part[] = [];\n  const post: Part[] = [];\n  const counts: Record<string, number> = {};\n  const keyOf = (type: string) => {\n    counts[type] = (counts[type] ?? -1) + 1;\n    return `${type}:${counts[type]}`;\n  };\n  let label = \"\";\n  let seenInteger = false;\n  let seenDecimal = false;\n  for (const part of parts) {\n    label += part.value;\n    const type = part.type === \"minusSign\" || part.type === \"plusSign\" ? \"sign\" : part.type;\n    if (type === \"integer\") {\n      seenInteger = true;\n      for (const char of part.value) integers.push({ type, value: Number.parseInt(char, 10), key: \"\" });\n    } else if (type === \"group\") integers.push({ type, value: part.value, key: \"\" });\n    else if (type === \"decimal\") {\n      seenDecimal = true;\n      fraction.push({ type, value: part.value, key: keyOf(type) });\n    } else if (type === \"fraction\") {\n      for (const char of part.value) {\n        const key = keyOf(type);\n        fraction.push({ type, value: Number.parseInt(char, 10), key, pos: -1 - (counts[type] ?? 0) });\n      }\n    } else (seenInteger || seenDecimal ? post : pre).push({ type, value: part.value, key: keyOf(type) });\n  }\n  const integer: Part[] = [];\n  for (let index = integers.length - 1; index >= 0; index--) {\n    const part = integers[index];\n    if (!part) continue;\n    const key = keyOf(part.type);\n    integer.unshift(part.type === \"integer\" ? { ...part, key, pos: counts[part.type] } : { ...part, key });\n  }\n  return { pre, integer, fraction, post, label, value };\n}\n\n/** The server's markup, and the first paint before the flow takes over: each character in a padded box */\nfunction staticHtml(data: Data) {\n  const escapeHtml = (text: string) => text.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\");\n  const all = [...data.pre, ...data.integer, ...data.fraction, ...data.post];\n  return `<span class=\"ticker-static\">${all.map((part) => `<span>${escapeHtml(String(part.value))}</span>`).join(\"\")}</span>`;\n}\n\nconst span = (props: { className?: string; part?: string; textContent?: string }, children: Node[] = []) => {\n  const element = document.createElement(\"span\");\n  if (props.className) element.className = props.className;\n  if (props.textContent !== undefined) element.textContent = props.textContent;\n  for (const child of children) element.appendChild(child);\n  return element;\n};\n\n/** Where a popped character sits, from the edge its section holds to */\nconst offsetFrom = (element: HTMLElement, edge: \"left\" | \"right\") =>\n  edge === \"left\"\n    ? element.offsetLeft\n    : ((element.offsetParent instanceof HTMLElement ? element.offsetParent.offsetWidth : 0) ?? 0) -\n      element.offsetWidth -\n      element.offsetLeft;\n\nconst accumulate = (effect: EffectTiming): KeyframeAnimationOptions => ({\n  ...effect,\n  composite: \"accumulate\",\n});\n\ntype Options = { trend: number | ((before: number, after: number) => number); continuous: boolean };\n\n/** The flow on one host: a plain object driving our span */\nclass Flow {\n  host: HTMLElement;\n  options: Options;\n  data: Data | null = null;\n  animated = true;\n  reduce = false;\n  computedAnimated = false;\n  computedTrend = 0;\n  /** Continuous: the highest place that changes, or nothing */\n  changedUpTo: number | undefined;\n  private wasReady = false;\n  private finishing: AbortController | undefined;\n  private pre!: SymbolSection;\n  private num!: NumberPart;\n  private post!: SymbolSection;\n\n  constructor(host: HTMLElement, options: Options) {\n    this.host = host;\n    this.options = options;\n  }\n\n  private ready() {\n    return (\n      canAnimate() && this.animated && !this.reduce && this.host.ownerDocument.visibilityState !== \"hidden\"\n    );\n  }\n\n  set(data: Data) {\n    if (data === this.data) return;\n    if (!this.data) {\n      this.data = data;\n      this.host.replaceChildren();\n      this.pre = new SymbolSection(this, data.pre, \"right\");\n      this.num = new NumberPart(this, data.integer, data.fraction);\n      this.post = new SymbolSection(this, data.post, \"left\");\n      this.host.append(this.pre.el, this.num.el, this.post.el);\n      return;\n    }\n    const before = this.data;\n    this.data = data;\n    const { trend, continuous } = this.options;\n    this.computedTrend = typeof trend === \"function\" ? trend(before.value, data.value) : trend;\n    this.computedAnimated =\n      this.ready() &&\n      // Nothing to animate while it isn't laid out (in a hidden tab panel, say)\n      this.host.offsetWidth > 0 &&\n      this.host.offsetHeight > 0;\n    this.changedUpTo = continuous && this.computedTrend ? highestChange(before, data) : undefined;\n    this.willUpdate();\n    this.pre.update(data.pre);\n    this.num.update(data.integer, data.fraction);\n    this.post.update(data.post);\n    this.didUpdate();\n  }\n\n  /** Changing `animated` finishes whatever is running */\n  setAnimated(animated: boolean) {\n    if (animated === this.animated) return;\n    this.animated = animated;\n    for (const animation of this.animations()) animation.finish();\n  }\n\n  animations() {\n    return this.host.getAnimations?.({ subtree: true }) ?? [];\n  }\n\n  private willUpdate() {\n    this.wasReady = this.ready();\n    if (!this.wasReady) return;\n    this.pre.willUpdate();\n    this.num.willUpdate();\n    this.post.willUpdate();\n  }\n\n  private didUpdate() {\n    if (!this.computedAnimated || !this.wasReady) return;\n    if (this.finishing) this.finishing.abort();\n    this.pre.didUpdate();\n    this.num.didUpdate();\n    this.post.didUpdate();\n    const controller = new AbortController();\n    Promise.all(this.animations().map((animation) => animation.finished)).then(\n      () => {\n        if (controller.signal.aborted) return;\n        this.host.dispatchEvent(new Event(\"animationsfinish\"));\n        this.finishing = undefined;\n      },\n      () => {},\n    );\n    this.finishing = controller;\n  }\n\n  /** Stops everything, for unmounting */\n  destroy() {\n    for (const animation of this.animations()) animation.cancel();\n  }\n}\n\n/** Continuous: the highest place where the digits differ, either way */\nfunction highestChange(before: Data, after: Data) {\n  const digits = (data: Data) =>\n    data.integer.concat(data.fraction).filter((part) => part.type === \"integer\" || part.type === \"fraction\");\n  const old = digits(before);\n  const next = digits(after);\n  const gone = old.find(\n    (part) => !next.find((other) => other.pos === part.pos && other.value === part.value),\n  );\n  const came = next.find(\n    (part) => !old.find((other) => part.pos === other.pos && part.value === other.value),\n  );\n  const a = gone?.pos;\n  const b = came?.pos;\n  return a == null ? b : b == null ? a : Math.max(a, b);\n}\n\n/** The number itself: the integer and fraction sections, inside the faded, counter-scaled frame */\nclass NumberPart {\n  el: HTMLElement;\n  private integer: IntegerSection;\n  private fraction: IntegerSection;\n  private prevWidth = 0;\n  private prevLeft = 0;\n\n  constructor(flow: Flow, integer: Part[], fraction: Part[]) {\n    this.integer = new IntegerSection(flow, integer, \"right\");\n    this.fraction = new IntegerSection(flow, fraction, \"left\");\n    const inner = span({ className: \"ticker-number-inner\" }, [this.integer.el, this.fraction.el]);\n    this.el = span({ className: \"ticker-number\" }, [inner]);\n  }\n\n  willUpdate() {\n    this.prevWidth = this.el.offsetWidth;\n    this.prevLeft = this.el.getBoundingClientRect().left;\n    this.integer.willUpdate();\n    this.fraction.willUpdate();\n  }\n\n  update(integer: Part[], fraction: Part[]) {\n    this.integer.update(integer);\n    this.fraction.update(fraction);\n  }\n\n  didUpdate() {\n    const rect = this.el.getBoundingClientRect();\n    this.integer.didUpdate();\n    this.fraction.didUpdate();\n    const dx = this.prevLeft - rect.left;\n    const width = this.el.offsetWidth;\n    const dw = this.prevWidth - width;\n    this.el.style.setProperty(\"--width\", String(width));\n    this.el.animate({ [DX]: [`${dx}px`, \"0px\"], [D_WIDTH]: [dw, 0] }, accumulate(timing.transform));\n  }\n}\n\n/** Something that fades in as it comes and out as it goes, then removes itself */\nclass Presence {\n  el: HTMLElement;\n  protected flow: Flow;\n  private shown = true;\n  private onRemove: (() => void) | undefined;\n  private remove = () => {\n    this.el.remove();\n    this.onRemove?.();\n  };\n\n  constructor(\n    flow: Flow,\n    el: HTMLElement,\n    { onRemove, animateIn = false }: { onRemove?: () => void; animateIn?: boolean },\n  ) {\n    this.flow = flow;\n    this.el = el;\n    this.onRemove = onRemove;\n    el.classList.add(\"ticker-presence\");\n    if (flow.computedAnimated && animateIn)\n      el.animate({ [D_OPACITY]: [-0.9999, 0] }, accumulate(timing.opacity));\n  }\n\n  get present() {\n    return this.shown;\n  }\n\n  set present(present: boolean) {\n    if (this.shown === present) return;\n    this.shown = present;\n    if (present) this.el.removeAttribute(\"inert\");\n    else this.el.setAttribute(\"inert\", \"\");\n    if (!this.flow.computedAnimated) {\n      if (!present) this.remove();\n      return;\n    }\n    this.el.style.setProperty(D_OPACITY, present ? \"0\" : \"-.999\");\n    this.el.animate({ [D_OPACITY]: present ? [-0.9999, 0] : [0.999, 0] }, accumulate(timing.opacity));\n    if (present) this.flow.host.removeEventListener(\"animationsfinish\", this.remove);\n    else this.flow.host.addEventListener(\"animationsfinish\", this.remove, { once: true });\n  }\n}\n\ntype Edge = \"left\" | \"right\";\n\nabstract class Char extends Presence {\n  section: Section;\n  value: number | string;\n  constructor(\n    section: Section,\n    value: number | string,\n    el: HTMLElement,\n    options: { onRemove?: () => void; animateIn?: boolean },\n  ) {\n    super(section.flow, el, options);\n    this.section = section;\n    this.value = value;\n  }\n  abstract willUpdate(sectionRect: DOMRect): void;\n  abstract update(value: number | string): void;\n  abstract didUpdate(sectionRect: DOMRect): void;\n}\n\n/** One digit: a column of 0–9 whose rows CSS places from the digit it shows */\nclass Digit extends Char {\n  pos: number;\n  private numbers: HTMLElement[];\n  private prevValue = 0;\n  private prevCenter = 0;\n  private stopSpinning = () => this.el.classList.remove(\"is-spinning\");\n\n  constructor(\n    section: Section,\n    type: string,\n    value: number,\n    pos: number,\n    options: { onRemove?: () => void; animateIn?: boolean },\n  ) {\n    const numbers = Array.from({ length: LENGTH }, (_, n) => {\n      const number = span({ className: \"ticker-digit-num\", textContent: String(n) });\n      if (n !== value) number.setAttribute(\"inert\", \"\");\n      number.style.setProperty(\"--n\", String(n));\n      return number;\n    });\n    const el = span({ className: `ticker-digit ticker-${type}-digit` }, numbers);\n    el.style.setProperty(\"--current\", String(value));\n    el.style.setProperty(\"--length\", String(LENGTH));\n    super(section, value, el, options);\n    this.pos = pos;\n    this.numbers = numbers;\n  }\n\n  private centre(rect: DOMRect, sectionRect: DOMRect) {\n    const edge = this.section.justify;\n    const offset = rect[edge] - sectionRect[edge];\n    const half = rect.width / 2;\n    return edge === \"left\" ? offset + half : offset - half;\n  }\n\n  willUpdate(sectionRect: DOMRect) {\n    this.prevValue = Number(this.value);\n    this.prevCenter = this.centre(this.el.getBoundingClientRect(), sectionRect);\n  }\n\n  update(value: number | string) {\n    const digit = Number(value);\n    this.el.style.setProperty(\"--current\", String(digit));\n    this.numbers.forEach((number, n) => {\n      if (n === digit) number.removeAttribute(\"inert\");\n      else number.setAttribute(\"inert\", \"\");\n    });\n    this.value = digit;\n  }\n\n  didUpdate(sectionRect: DOMRect) {\n    const shift = this.prevCenter - this.centre(this.el.getBoundingClientRect(), sectionRect);\n    if (shift)\n      this.el.animate({ transform: [`translateX(${shift}px)`, \"none\"] }, accumulate(timing.transform));\n    const delta = this.delta();\n    if (!delta) return;\n    this.el.classList.add(\"is-spinning\");\n    this.el.animate({ [D]: [-delta, 0] }, accumulate(timing.transform));\n    this.flow.host.addEventListener(\"animationsfinish\", this.stopSpinning, { once: true });\n  }\n\n  /** How far it spins: the way the trend says, round the column if it has to; a whole turn when continuous */\n  private delta() {\n    const value = Number(this.value);\n    const diff = value - this.prevValue;\n    const { changedUpTo, computedTrend } = this.flow;\n    if (!diff && changedUpTo != null && changedUpTo >= this.pos) return LENGTH * computedTrend;\n    const trend = computedTrend || Math.sign(diff);\n    if (trend < 0 && value > this.prevValue) return value - LENGTH - this.prevValue;\n    if (trend > 0 && value < this.prevValue) return LENGTH - this.prevValue + value;\n    return diff;\n  }\n}\n\n/** A separator, sign or symbol: a changed one cross-fades */\nclass Symbol_ extends Char {\n  type: string;\n  private values = new Map<string, Presence>();\n  private prevOffset = 0;\n\n  constructor(\n    section: Section,\n    type: string,\n    value: string,\n    options: { onRemove?: () => void; animateIn?: boolean },\n  ) {\n    const inner = span({ className: \"ticker-symbol-value\", textContent: value });\n    super(section, value, span({ className: `ticker-symbol ticker-${type}` }, [inner]), options);\n    this.type = type;\n    this.values.set(value, new Presence(this.flow, inner, { onRemove: () => this.values.delete(value) }));\n  }\n\n  willUpdate(sectionRect: DOMRect) {\n    if (this.type === \"decimal\") return;\n    const edge = this.section.justify;\n    this.prevOffset = this.el.getBoundingClientRect()[edge] - sectionRect[edge];\n  }\n\n  update(value: number | string) {\n    const text = String(value);\n    if (this.value !== text) {\n      const old = this.values.get(String(this.value));\n      if (old) old.present = false;\n      const kept = this.values.get(text);\n      if (kept) kept.present = true;\n      else {\n        const inner = span({ className: \"ticker-symbol-value\", textContent: text });\n        this.el.appendChild(inner);\n        this.values.set(\n          text,\n          new Presence(this.flow, inner, { animateIn: true, onRemove: () => this.values.delete(text) }),\n        );\n      }\n    }\n    this.value = text;\n  }\n\n  didUpdate(sectionRect: DOMRect) {\n    if (this.type === \"decimal\") return;\n    const edge = this.section.justify;\n    const shift = this.prevOffset - (this.el.getBoundingClientRect()[edge] - sectionRect[edge]);\n    if (shift)\n      this.el.animate({ transform: [`translateX(${shift}px)`, \"none\"] }, accumulate(timing.transform));\n  }\n}\n\n/** A run of characters held to one edge: characters that go are popped out where they were, then fade */\nabstract class Section {\n  flow: Flow;\n  el: HTMLElement;\n  justify: Edge;\n  protected children = new Map<string, Char>();\n  private prevOffset = 0;\n\n  constructor(flow: Flow, parts: Part[], justify: Edge) {\n    this.flow = flow;\n    this.justify = justify;\n    const chars = parts.map((part) => this.add(part).el);\n    this.el = span({ className: `ticker-section ticker-justify-${justify}` }, chars);\n  }\n\n  protected add(part: Part, { startAtZero = false, animateIn = false } = {}): Char {\n    const onRemove = () => this.children.delete(part.key);\n    const char =\n      part.type === \"integer\" || part.type === \"fraction\"\n        ? new Digit(this, part.type, startAtZero ? 0 : Number(part.value), part.pos ?? 0, {\n            onRemove,\n            animateIn,\n          })\n        : new Symbol_(this, part.type, String(part.value), { onRemove, animateIn });\n    this.children.set(part.key, char);\n    return char;\n  }\n\n  protected unpop(char: Char) {\n    char.el.removeAttribute(\"inert\");\n    char.el.style.top = \"\";\n    char.el.style[this.justify] = \"\";\n  }\n\n  protected pop(chars: Map<string, Char>) {\n    for (const char of chars.values()) {\n      char.el.style.top = `${char.el.offsetTop}px`;\n      char.el.style[this.justify] = `${offsetFrom(char.el, this.justify)}px`;\n    }\n    for (const char of chars.values()) {\n      char.el.setAttribute(\"inert\", \"\");\n      char.present = false;\n    }\n  }\n\n  protected addNewAndUpdateExisting(parts: Part[]) {\n    const added = new Map<Part, Char>();\n    const kept = new Map<Part, Char>();\n    const left = this.justify === \"left\";\n    const ordered = left ? [...parts].reverse() : parts;\n    for (const part of ordered) {\n      let char = this.children.get(part.key);\n      if (char) {\n        kept.set(part, char);\n        this.unpop(char);\n        char.present = true;\n      } else {\n        char = this.add(part, { startAtZero: true, animateIn: true });\n        added.set(part, char);\n      }\n      if (left) this.el.prepend(char.el);\n      else this.el.append(char.el);\n    }\n    if (this.flow.computedAnimated) {\n      const rect = this.el.getBoundingClientRect();\n      for (const char of added.values()) char.willUpdate(rect);\n    }\n    for (const [part, char] of added) char.update(part.value);\n    for (const [part, char] of kept) char.update(part.value);\n  }\n\n  willUpdate() {\n    const rect = this.el.getBoundingClientRect();\n    this.prevOffset = rect[this.justify];\n    for (const char of this.children.values()) char.willUpdate(rect);\n  }\n\n  didUpdate() {\n    const rect = this.el.getBoundingClientRect();\n    for (const char of this.children.values()) char.didUpdate(rect);\n    const shift = this.prevOffset - rect[this.justify];\n    if (shift && this.children.size)\n      this.el.animate({ transform: [`translateX(${shift}px)`, \"none\"] }, accumulate(timing.transform));\n  }\n\n  abstract update(parts: Part[]): void;\n}\n\n/** The integer or fraction digits: a digit that goes spins down to 0 as it fades */\nclass IntegerSection extends Section {\n  update(parts: Part[]) {\n    const removed = new Map<string, Char>();\n    for (const [key, char] of this.children) {\n      if (!parts.find((part) => part.key === key)) removed.set(key, char);\n      this.unpop(char);\n    }\n    this.addNewAndUpdateExisting(parts);\n    for (const char of removed.values()) if (char instanceof Digit) char.update(0);\n    this.pop(removed);\n  }\n}\n\n/** What comes before or after the number */\nclass SymbolSection extends Section {\n  update(parts: Part[]) {\n    const removed = new Map<string, Char>();\n    for (const [key, char] of this.children)\n      if (!parts.find((part) => part.key === key)) removed.set(key, char);\n    this.pop(removed);\n    this.addNewAndUpdateExisting(parts);\n  }\n}\n\nexport type NumberTickerProps = Omit<ComponentProps<\"span\">, \"children\" | \"prefix\"> & {\n  /** The number to show */\n  value: number;\n  /** Where the first roll starts */\n  from?: number;\n  /** Intl.NumberFormat options, for example { style: \"currency\", currency: \"USD\" } */\n  format?: Intl.NumberFormatOptions;\n  /** The locale to format in. en-US unless you pass one, so the server and the browser agree */\n  locale?: string;\n  /** Text before the number */\n  prefix?: string;\n  /** Text after the number */\n  suffix?: string;\n  /** Which way the digits turn. Up when the number grows and down when it shrinks, unless you set it */\n  direction?: \"up\" | \"down\";\n  /** When a higher place changes, the places below spin a whole turn, so it reads as counting */\n  continuous?: boolean;\n  /** Seconds to wait before the first roll */\n  delay?: number;\n  /** Roll when it scrolls into view (once). With false, it rolls on mount */\n  startOnView?: boolean;\n};\n\n/**\n * A number that flows to its value, digit by digit. It inherits the text's size and colour; set them with\n * `className`. Its line height is 1, so give it a size rather than relying on the line around it.\n */\nfunction NumberTicker({\n  value,\n  from = 0,\n  format,\n  locale = \"en-US\",\n  prefix,\n  suffix,\n  direction,\n  continuous = true,\n  delay = 0,\n  startOnView = true,\n  className,\n  style,\n  ref,\n  ...props\n}: NumberTickerProps) {\n  // Format options compared by content, so an inline object doesn't make a new formatter each render\n  const formatKey = JSON.stringify(format ?? {});\n  // biome-ignore lint/correctness/useExhaustiveDependencies: format is compared through formatKey\n  const formatter = useMemo(() => new Intl.NumberFormat(locale, format), [locale, formatKey]);\n  const data = useMemo(() => toData(value, formatter, prefix, suffix), [value, formatter, prefix, suffix]);\n  const reduce = useReducedMotion() ?? false;\n  const host = useRef<HTMLSpanElement | null>(null);\n  const flow = useRef<Flow | null>(null);\n  // Whether the first roll has happened; until then a new value waits for it\n  const started = useRef(false);\n  // The server's markup and the first paint; fixed, so React never touches what the flow builds inside\n  const [initial] = useState(() => ({ __html: staticHtml(data) }));\n  const latest = useRef({ data, from, formatter, prefix, suffix, delay, startOnView, reduce });\n  latest.current = { data, from, formatter, prefix, suffix, delay, startOnView, reduce };\n  const options: Options = {\n    trend: direction === \"up\" ? 1 : direction === \"down\" ? -1 : (before, after) => Math.sign(after - before),\n    continuous,\n  };\n  if (flow.current) flow.current.options = options;\n\n  const setRef = useCallback(\n    (node: HTMLSpanElement | null) => {\n      host.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  // The flow takes over the server's markup before the first paint: at `from`, then the value once it's seen\n  // biome-ignore lint/correctness/useExhaustiveDependencies: built once; later values go through the effect below\n  useLayoutEffect(() => {\n    const node = host.current;\n    if (!node) return;\n    const settings = latest.current;\n    const engine = new Flow(node, options);\n    flow.current = engine;\n    engine.reduce = settings.reduce;\n    const rolls = !settings.reduce && canAnimate();\n    engine.set(\n      rolls ? toData(settings.from, settings.formatter, settings.prefix, settings.suffix) : settings.data,\n    );\n    let timer: ReturnType<typeof setTimeout> | undefined;\n    const go = () => {\n      timer = setTimeout(() => {\n        started.current = true;\n        engine.set(latest.current.data);\n      }, settings.delay * 1000);\n    };\n    let observer: IntersectionObserver | undefined;\n    if (!rolls) started.current = true;\n    else if (!settings.startOnView || typeof IntersectionObserver === \"undefined\") go();\n    else {\n      observer = new IntersectionObserver(\n        (entries) => {\n          if (!entries.some((entry) => entry.isIntersecting && entry.intersectionRatio >= IN_VIEW)) return;\n          observer?.disconnect();\n          go();\n        },\n        { threshold: IN_VIEW },\n      );\n      observer.observe(node);\n    }\n    return () => {\n      observer?.disconnect();\n      clearTimeout(timer);\n      engine.destroy();\n      flow.current = null;\n      started.current = false;\n    };\n  }, []);\n\n  // Later values flow from what's on screen\n  useLayoutEffect(() => {\n    const engine = flow.current;\n    if (!engine || !started.current) return;\n    engine.reduce = reduce;\n    engine.set(data);\n  }, [data, reduce]);\n\n  return (\n    <>\n      {/* One stylesheet for the page: React hoists it to the head and keeps one copy however many tickers */}\n      <style href=\"fewtone-number-ticker\" precedence=\"medium\">\n        {CSS_TEXT}\n      </style>\n      <span\n        ref={setRef}\n        data-slot=\"number-ticker\"\n        // The number is a picture named by its value, so screen readers read it once and never the roll\n        role=\"img\"\n        aria-label={data.label}\n        className={cn(\"tabular-nums\", className)}\n        style={style as CSSProperties}\n        {...props}\n        // biome-ignore lint/security/noDangerouslySetInnerHtml: our own escaped digits, the server's markup; the flow builds inside it after\n        dangerouslySetInnerHTML={initial}\n      />\n    </>\n  );\n}\n\nexport { NumberTicker };\n"
    }
  ],
  "categories": [
    "effects"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/number-ticker"
}
