{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion-number",
  "title": "Motion number",
  "description": "A formatted number whose digits roll to their new value in the direction of the change.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "components/core/motion-number.tsx",
      "content": "'use client';\nimport { cn } from '@/lib/utils';\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n  type SpringOptions,\n  type Transition,\n} from 'motion/react';\nimport { useEffect, useRef, useState } from 'react';\n\ntype Trend = 1 | -1;\n\ntype DigitSlot = {\n  kind: 'digit';\n  key: string;\n  digit: number;\n  glyph: string;\n};\n\ntype SymbolSlot = {\n  kind: 'symbol';\n  key: string;\n  text: string;\n};\n\ntype Slot = DigitSlot | SymbolSlot;\n\nconst DIGIT_COUNT = 10;\n\nconst HALF_WHEEL = DIGIT_COUNT / 2;\n\nconst WHEEL = Array.from({ length: DIGIT_COUNT }, (_, digit) => digit);\n\nconst ROLL_SPRING: SpringOptions = { stiffness: 170, damping: 28, mass: 1 };\n\nconst SLIDE: Transition = { type: 'spring', bounce: 0, duration: 0.5 };\n\nconst FADE_IN: Transition = { duration: 0.2, ease: [0.23, 1, 0.32, 1] };\n\nconst FADE_OUT: Transition = { duration: 0.12, ease: [0.23, 1, 0.32, 1] };\n\nconst INSTANT: Transition = { duration: 0 };\n\nfunction wrapDigit(position: number) {\n  const remainder = position % DIGIT_COUNT;\n  const positiveRemainder = remainder + DIGIT_COUNT;\n  return positiveRemainder % DIGIT_COUNT;\n}\n\n/** Distance of a glyph from the visible window, in slot heights: positive sits below, negative above. */\nfunction offsetOf(digit: number, position: number) {\n  const distance = wrapDigit(digit - position);\n  if (distance > HALF_WHEEL) {\n    return distance - DIGIT_COUNT;\n  }\n  return distance;\n}\n\nfunction translateOf(offset: number) {\n  const percent = offset * 100;\n  return `translateY(${percent}%)`;\n}\n\nfunction stepsBetween(from: number, to: number, trend: Trend) {\n  if (trend === 1) {\n    return wrapDigit(to - from);\n  }\n  const stepsDown = wrapDigit(from - to);\n  return -stepsDown;\n}\n\nfunction trendBetween(previous: number, next: number): Trend {\n  if (next < previous) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction glyphsOf(formatter: Intl.NumberFormat) {\n  const { numberingSystem } = formatter.resolvedOptions();\n  const digitFormatter = new Intl.NumberFormat('en-US', {\n    numberingSystem,\n    useGrouping: false,\n  });\n  return WHEEL.map((digit) => digitFormatter.format(digit));\n}\n\nfunction countIntegerDigits(parts: Intl.NumberFormatPart[]) {\n  const integerParts = parts.filter((part) => part.type === 'integer');\n  const integerValues = integerParts.map((part) => part.value);\n  const integerText = integerValues.join('');\n  return Array.from(integerText).length;\n}\n\nfunction slotOfCharacter(key: string, character: string, glyphs: string[]) {\n  const digit = glyphs.indexOf(character);\n  if (digit === -1) {\n    const symbol: SymbolSlot = { kind: 'symbol', key, text: character };\n    return symbol;\n  }\n  const digitSlot: DigitSlot = { kind: 'digit', key, digit, glyph: character };\n  return digitSlot;\n}\n\n/**\n * Integer digits are keyed by their place from the right and fraction digits by their place from\n * the decimal point, so 99 to 100 keeps the two existing digits and adds one on the left.\n */\nfunction slotsOf(parts: Intl.NumberFormatPart[], glyphs: string[]) {\n  const slots: Slot[] = [];\n  const occurrences = new Map<string, number>();\n  let integerPlace = countIntegerDigits(parts);\n  let fractionPlace = 0;\n\n  for (const part of parts) {\n    if (part.type === 'integer') {\n      for (const character of Array.from(part.value)) {\n        integerPlace -= 1;\n        const slot = slotOfCharacter(\n          `integer-${integerPlace}`,\n          character,\n          glyphs\n        );\n        slots.push(slot);\n      }\n      continue;\n    }\n\n    if (part.type === 'fraction') {\n      for (const character of Array.from(part.value)) {\n        const slot = slotOfCharacter(\n          `fraction-${fractionPlace}`,\n          character,\n          glyphs\n        );\n        slots.push(slot);\n        fractionPlace += 1;\n      }\n      continue;\n    }\n\n    if (part.type === 'group') {\n      slots.push({\n        kind: 'symbol',\n        key: `group-${integerPlace}`,\n        text: part.value,\n      });\n      continue;\n    }\n\n    const occurrence = occurrences.get(part.type) ?? 0;\n    const nextOccurrence = occurrence + 1;\n    occurrences.set(part.type, nextOccurrence);\n    slots.push({\n      kind: 'symbol',\n      key: `${part.type}-${occurrence}`,\n      text: part.value,\n    });\n  }\n\n  return slots;\n}\n\nfunction slotMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return {\n      initial: { opacity: 0 },\n      animate: { opacity: 1, transition: INSTANT },\n      exit: { opacity: 0, transition: INSTANT },\n      transition: INSTANT,\n    };\n  }\n  return {\n    initial: { opacity: 0 },\n    animate: { opacity: 1, transition: FADE_IN },\n    exit: { opacity: 0, transition: FADE_OUT },\n    transition: { layout: SLIDE },\n  };\n}\n\nfunction WheelGlyph({\n  position,\n  digit,\n  glyph,\n}: {\n  position: MotionValue<number>;\n  digit: number;\n  glyph: string;\n}) {\n  const transform = useTransform(position, (latest) => {\n    const offset = offsetOf(digit, latest);\n    return translateOf(offset);\n  });\n\n  return (\n    <motion.span\n      className='absolute inset-0 flex justify-center'\n      style={{ transform }}\n    >\n      {glyph}\n    </motion.span>\n  );\n}\n\nfunction DigitWheel({\n  digit,\n  glyph,\n  glyphs,\n  trend,\n  shouldReduceMotion,\n}: {\n  digit: number;\n  glyph: string;\n  glyphs: string[];\n  trend: Trend;\n  shouldReduceMotion: boolean | null;\n}) {\n  const position = useSpring(digit, ROLL_SPRING);\n  const targetRef = useRef(digit);\n\n  useEffect(() => {\n    const restingDigit = wrapDigit(targetRef.current);\n    if (restingDigit === digit) {\n      return;\n    }\n    const steps = stepsBetween(restingDigit, digit, trend);\n    targetRef.current += steps;\n    if (shouldReduceMotion) {\n      position.jump(targetRef.current);\n      return;\n    }\n    position.set(targetRef.current);\n  }, [digit, trend, shouldReduceMotion, position]);\n\n  return (\n    <>\n      <span className='invisible'>{glyph}</span>\n      {WHEEL.map((wheelDigit) => (\n        <WheelGlyph\n          key={wheelDigit}\n          position={position}\n          digit={wheelDigit}\n          glyph={glyphs[wheelDigit]}\n        />\n      ))}\n    </>\n  );\n}\n\nexport type MotionNumberProps = {\n  value: number;\n  locale?: Intl.LocalesArgument;\n  format?: Intl.NumberFormatOptions;\n  prefix?: string;\n  suffix?: string;\n  className?: string;\n};\n\nexport function MotionNumber({\n  value,\n  locale = 'en-US',\n  format,\n  prefix,\n  suffix,\n  className,\n}: MotionNumberProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [previousValue, setPreviousValue] = useState(value);\n  const [trend, setTrend] = useState<Trend>(1);\n\n  const hasChanged = !Object.is(value, previousValue);\n  if (hasChanged) {\n    setPreviousValue(value);\n    setTrend(trendBetween(previousValue, value));\n  }\n\n  const formatter = new Intl.NumberFormat(locale, format);\n  const glyphs = glyphsOf(formatter);\n  const parts = formatter.formatToParts(value);\n  const slots = slotsOf(parts, glyphs);\n  const formattedValue = formatter.format(value);\n  const spokenValue = [prefix, formattedValue, suffix].join('');\n  const slotMotion = slotMotionOf(shouldReduceMotion);\n\n  return (\n    <span\n      className={cn(\n        'inline-flex items-baseline whitespace-nowrap tabular-nums',\n        className\n      )}\n    >\n      <span className='sr-only'>{spokenValue}</span>\n      <span\n        aria-hidden='true'\n        className='relative inline-flex items-baseline whitespace-pre'\n      >\n        {prefix && (\n          <motion.span layout='position' transition={slotMotion.transition}>\n            {prefix}\n          </motion.span>\n        )}\n        <AnimatePresence initial={false} mode='popLayout'>\n          {slots.map((slot) => {\n            if (slot.kind === 'symbol') {\n              return (\n                <motion.span key={slot.key} layout='position' {...slotMotion}>\n                  {slot.text}\n                </motion.span>\n              );\n            }\n            return (\n              <motion.span\n                key={slot.key}\n                layout='position'\n                className='relative inline-flex overflow-y-clip'\n                {...slotMotion}\n              >\n                <DigitWheel\n                  digit={slot.digit}\n                  glyph={slot.glyph}\n                  glyphs={glyphs}\n                  trend={trend}\n                  shouldReduceMotion={shouldReduceMotion}\n                />\n              </motion.span>\n            );\n          })}\n        </AnimatePresence>\n        {suffix && (\n          <motion.span layout='position' transition={slotMotion.transition}>\n            {suffix}\n          </motion.span>\n        )}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/core/motion-number.tsx"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}