{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number",
  "type": "registry:ui",
  "title": "Number",
  "description": "A number that rolls to its new value: every digit is its own column on a spring, the columns start one after another from the right, and a new leading digit opens in width. Separators follow the locale; screen readers get the final value at once.",
  "author": "ARLing s. r. o. (https://arling.sk)",
  "registryDependencies": [
    "https://arling.sk/motion/r/core.json"
  ],
  "files": [
    {
      "path": "registry/arling/lib/arling-motion-number.js",
      "content": "'use strict';\n// ARLing Motion: number. MIT licence, https://arling.sk/motion/ . Installed from the ARLing\n// Motion registry (source: components/number/number.js). Plain JavaScript; the .d.ts next to it types the exports.\n/*\n * ARLing Motion: Number.\n * A number that rolls to its new value. Every digit is its own column on the snappy spring\n * (one small overshoot, under 3 %), and the columns start one after another from the right,\n * so a change reads as a count and not as a flicker. Leading columns open and close in width\n * on the smooth spring when the number gains or loses a digit. Group and decimal separators\n * follow the locale (Intl.NumberFormat).\n *\n * Markup:\n *   <span class=\"am-number\" data-value=\"1204.5\" data-decimals=\"2\" data-prefix=\"€\"></span>\n * The component adds the parts. Screen readers get the final value as text at once (and an\n * aria-live announcement when live is set); the rolling columns are aria-hidden.\n * MIT licence.\n */\nimport { track, driver, attr, PRESETS } from './arling-motion';\n\n/** Seconds between the start of neighbouring columns, from the right. */\nexport const STAGGER = 0.035;\n\nconst round2 = (v) => Math.round(v * 100) / 100;\n\n/** Separators of a locale, e.g. { group: ',', decimal: '.' } for en-US. */\nexport function separators(locale) {\n  const parts = new Intl.NumberFormat(locale, { minimumFractionDigits: 1 }).formatToParts(12345.6);\n  const pick = (type, fallback) => (parts.find((p) => p.type === type) || { value: fallback }).value;\n  return { group: pick('group', ','), decimal: pick('decimal', '.') };\n}\n\n/** The digit at place p (0 = units, -1 = tenths) of |value| rounded to `decimals`. */\nexport function digitAt(value, p, decimals) {\n  const n = Math.round(Math.abs(value) * 10 ** decimals);\n  return Math.floor(n / 10 ** (p + decimals)) % 10;\n}\n\n/** Highest place with a non zero digit, at least 0 (units are always shown). */\nexport function topPlace(value, decimals) {\n  const n = Math.round(Math.abs(value) * 10 ** decimals);\n  const whole = Math.floor(n / 10 ** decimals);\n  return whole > 0 ? Math.floor(Math.log10(whole) + 1e-9) : 0;\n}\n\n/**\n * createNumber({ el, value, decimals, prefix, suffix, locale, grouping, live, clock, reduced })\n * Returns { set, value, seek, settled, destroy, driver, keep, el }.\n */\nexport function createNumber(o) {\n  const el = o.el;\n  const doc = el.ownerDocument || document;\n  const data = (k) => (el.hasAttribute(`data-${k}`) ? el.getAttribute(`data-${k}`) : undefined);\n  const num = (v, d) => (v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));\n  const decimals = Math.max(0, Math.min(6, Math.round(num(o.decimals ?? data('decimals'), 0))));\n  const prefix = o.prefix ?? data('prefix') ?? '';\n  const suffix = o.suffix ?? data('suffix') ?? '';\n  const locale = o.locale ?? data('locale') ?? 'en-US';\n  const grouping = o.grouping ?? data('grouping') !== 'false';\n  const sep = separators(locale);\n  const fmt = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: grouping });\n  const text = (v) => `${v < 0 ? '−' : ''}${prefix}${fmt.format(Math.abs(v))}${suffix}`;\n\n  const make = (tag, cls, content) => {\n    const s = doc.createElement(tag);\n    s.className = cls;\n    if (content !== undefined) s.textContent = content;\n    return s;\n  };\n  el.textContent = '';\n  el.classList.add('am-number');\n  const sr = make('span', 'am-number-sr');\n  const roll = make('span', 'am-number-roll');\n  attr(roll, 'aria-hidden', 'true');\n  if (o.live) attr(sr, 'aria-live', o.live === true ? 'polite' : o.live);\n  el.appendChild(sr);\n  el.appendChild(roll);\n  const sign = make('span', 'am-number-sign', '−');\n  const pre = make('span', 'am-number-affix', prefix);\n  const post = make('span', 'am-number-affix', suffix);\n  roll.appendChild(sign);\n  if (prefix) roll.appendChild(pre);\n\n  // columns by place, from the highest; each has a digit track and an \"open\" track (width)\n  const cols = new Map();\n  let first = null; // leftmost column element, new higher places go before it\n  function column(p) {\n    if (cols.has(p)) return cols.get(p);\n    const box = make('span', 'am-number-col');\n    const strip = make('span', 'am-number-strip');\n    for (let d = 0; d < 10; d++) strip.appendChild(make('span', 'am-number-digit', String(d)));\n    box.appendChild(strip);\n    const c = { p, box, strip, pos: track(0, PRESETS.snappy), open: track(p <= 0 ? 1 : 0, PRESETS.smooth), gap: null };\n    if (p >= 3 && p % 3 === 0 && grouping) {\n      c.gap = make('span', 'am-number-sep', sep.group);\n    }\n    return c;\n  }\n  function ensure(top) {\n    const low = -decimals;\n    let hi = cols.size ? Math.max(...cols.keys()) : low - 1;\n    if (!cols.size) {\n      for (let p = Math.max(top, 0); p >= low; p--) {\n        const c = column(p);\n        cols.set(p, c);\n        roll.appendChild(c.box);\n        if (c.gap) roll.appendChild(c.gap);\n        if (p === 0 && decimals) roll.appendChild(make('span', 'am-number-sep am-number-point', sep.decimal));\n      }\n      first = cols.get(Math.max(top, 0)).box;\n      if (suffix) roll.appendChild(post);\n      return;\n    }\n    for (let p = hi + 1; p <= top; p++) {\n      const c = column(p);\n      cols.set(p, c);\n      roll.insertBefore(c.box, first);\n      if (c.gap) roll.insertBefore(c.gap, first);\n      first = c.box;\n    }\n  }\n\n  let current = num(o.value ?? data('value'), 0);\n  const signTrack = track(current < 0 ? 1 : 0, PRESETS.smooth);\n  ensure(topPlace(current, decimals));\n  for (const c of cols.values()) {\n    c.pos.initial = digitAt(current, c.p, decimals);\n    c.open.initial = c.p <= topPlace(current, decimals) ? 1 : 0;\n  }\n  sr.textContent = text(current);\n  attr(el, 'data-state', 'idle');\n  let endT = -Infinity;\n  const all = () => [signTrack, ...[...cols.values()].flatMap((c) => [c.pos, c.open])];\n  const settledAll = (t) => all().every((tr) => tr.settled(t));\n\n  function paint(t, { reduced }) {\n    const at = (tr) => (reduced ? tr.target(t) : tr.at(t));\n    const s = Math.max(0, Math.min(1, at(signTrack)));\n    sign.style.width = `${round2(s * 0.62)}em`;\n    sign.style.opacity = String(round2(s));\n    for (const c of cols.values()) {\n      const open = Math.max(0, Math.min(1, at(c.open)));\n      c.box.style.width = `${round2(open)}ch`;\n      c.box.style.opacity = String(round2(open));\n      c.strip.style.transform = `translateY(${round2(-at(c.pos) * 10)}%)`;\n      if (c.gap) {\n        c.gap.style.width = open > 0.5 ? '' : '0';\n        c.gap.style.opacity = String(round2(Math.max(0, open * 2 - 1)));\n      }\n    }\n    attr(el, 'data-state', settledAll(t) ? 'idle' : 'rolling');\n  }\n\n  function draw(t, st) {\n    paint(t, st);\n    if (!api.keep && t >= endT && settledAll(t)) for (const tr of all()) tr.compact(t);\n  }\n\n  const d = driver(draw, { clock: o.clock, reduced: o.reduced });\n  d.busy = (t) => t < endT || !settledAll(t);\n  const when = (opt) => (opt && opt.t !== undefined ? opt.t : d.now());\n\n  /** Rolls to value. opt: { t } to schedule it (demo, video). */\n  function set(value, opt = {}) {\n    const v = num(value, current);\n    const t = when(opt);\n    const top = topPlace(v, decimals);\n    ensure(top);\n    const places = [...cols.keys()].sort((a, b) => a - b); // from the right\n    let k = 0;\n    for (const p of places) {\n      const c = cols.get(p);\n      const target = digitAt(v, p, decimals);\n      const start = t + (o.stagger ?? STAGGER) * k;\n      if (c.pos.target(Infinity) !== target) {\n        c.pos.to(start, target);\n        k++;\n        if (start > endT) endT = start;\n      }\n      const openTarget = p <= Math.max(top, 0) ? 1 : 0;\n      if (c.open.target(Infinity) !== openTarget) c.open.to(t, openTarget);\n    }\n    signTrack.to(t, v < 0 ? 1 : 0);\n    if (t > endT) endT = t;\n    current = v;\n    sr.textContent = text(v);\n    paint(d.now(), { reduced: d.reduced });\n    d.kick();\n    return api;\n  }\n\n  const api = {\n    el,\n    driver: d,\n    keep: false,\n    set,\n    value: () => current,\n    text: () => text(current),\n    seek: (t) => paint(t, { reduced: d.reduced }),\n    settled: (t) => t >= endT && settledAll(t),\n    destroy() { d.stop(); },\n  };\n  paint(d.now(), { reduced: d.reduced });\n  return api;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/arling/lib/arling-motion-number.d.ts",
      "content": "// ARLing Motion: number. MIT licence, https://arling.sk/motion/ . Types for the plain JavaScript file next to this one.\nexport declare const STAGGER: any;\nexport declare function separators(...args: any[]): any;\nexport declare function digitAt(...args: any[]): any;\nexport declare function topPlace(...args: any[]): any;\nexport declare function createNumber(...args: any[]): any;\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/arling/ui/motion-number.tsx",
      "content": "'use client';\n// ARLing Motion: Number for React. A thin wrapper: the vanilla component owns the digit\n// springs, the columns that open for a new digit and the text for screen readers.\n// In the registry the core lives at '@/lib/arling-motion' and this logic at\n// '@/lib/arling-motion-number'.\nimport * as React from 'react';\nimport { createNumber } from '@/lib/arling-motion-number';\n\ntype NumberApi = { set: (v: number) => unknown; value: () => number; destroy: () => void };\n\nexport interface NumberTickerProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'children'> {\n  value: number;\n  decimals?: number;\n  prefix?: string;\n  suffix?: string;\n  /** BCP 47 locale for group and decimal separators, default en-US. */\n  locale?: string;\n  grouping?: boolean;\n  /** Announce changes to screen readers (aria-live). */\n  live?: boolean | 'polite' | 'assertive';\n  reducedMotion?: boolean;\n}\n\nexport function NumberTicker({ value, decimals = 0, prefix = '', suffix = '', locale = 'en-US', grouping = true, live, reducedMotion, className, ...rest }: NumberTickerProps) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const apiRef = React.useRef<NumberApi | null>(null);\n\n  React.useEffect(() => {\n    if (!ref.current) return;\n    const api = createNumber({ el: ref.current, value, decimals, prefix, suffix, locale, grouping, live, reduced: reducedMotion }) as unknown as NumberApi;\n    apiRef.current = api;\n    return () => {\n      api.destroy();\n      apiRef.current = null;\n    };\n    // built once per format; later values go through set()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [decimals, prefix, suffix, locale, grouping, live, reducedMotion]);\n\n  React.useEffect(() => {\n    const api = apiRef.current;\n    if (api && value !== api.value()) api.set(value);\n  }, [value]);\n\n  return <span ref={ref} className={['am-number', className].filter(Boolean).join(' ')} {...rest} />;\n}\n\nexport default NumberTicker;\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@layer components": {
      ".am-number": {
        "position": "relative",
        "display": "inline-flex",
        "align-items": "baseline",
        "white-space": "nowrap",
        "font-variant-numeric": "tabular-nums"
      },
      ".am-number-roll": {
        "display": "inline-flex",
        "align-items": "flex-end"
      },
      ".am-number-col": {
        "display": "inline-block",
        "width": "1ch",
        "height": "1.2em",
        "overflow": "hidden",
        "line-height": "1.2em",
        "text-align": "center",
        "-webkit-mask-image": "linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent)",
        "mask-image": "linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent)"
      },
      ".am-number-strip": {
        "display": "flex",
        "flex-direction": "column"
      },
      ".am-number-digit": {
        "display": "block",
        "height": "1.2em",
        "line-height": "1.2em"
      },
      ".am-number-sep, .am-number-affix, .am-number-sign": {
        "display": "inline-block",
        "height": "1.2em",
        "line-height": "1.2em",
        "overflow": "hidden",
        "font-variant-numeric": "normal"
      },
      ".am-number-sign": {
        "width": "0",
        "opacity": "0"
      },
      ".am-number-sr": {
        "position": "absolute",
        "width": "1px",
        "height": "1px",
        "overflow": "hidden",
        "clip": "rect(0 0 0 0)",
        "clip-path": "inset(50%)",
        "white-space": "nowrap"
      }
    }
  },
  "docs": "Number from ARLing Motion (MIT).\nReact: import { NumberTicker } from \"@/components/ui/motion-number\".\nThe logic is in lib/arling-motion-number.js (typed by the .d.ts next to it) and works without React: createNumber on your own markup.\nStyles were added to your global CSS under @layer components (Tailwind v4).\nWith Tailwind v3 or no Tailwind, add https://arling.sk/motion/components/number/number.css to your styles instead.\nLive demo and docs: https://arling.sk/motion/#number",
  "categories": [
    "motion",
    "number",
    "counter",
    "ticker",
    "price"
  ]
}
