{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "count-up",
  "title": "Count Up",
  "description": "A number that counts to its value without moving what sits beside it, formatted by Intl, with rolling digits, tiles or a meter line.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/count-up.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport { animate, useInView, useMotionValue, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { MeterFigure, PlainFigure } from \"@/registry/miseui/ui/count-up-figure\"\nimport { CountReels } from \"@/registry/miseui/ui/count-up-reels\"\nimport { UNAVAILABLE_MARK, createCountFormat, widerTokens, type CountFormatInput } from \"@/registry/miseui/ui/count-up-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype CountUpVariant = \"plain\" | \"odometer\" | \"board\" | \"meter\"\ntype CountUpEffect = \"retarget\"\ntype CountUpTrigger = \"view\" | \"mount\"\n\nconst DEFAULT_DURATION_SECONDS = 1.2\nconst VIEW_MARGIN = \"0px 0px -10% 0px\"\n\ninterface CountRunInput {\n  ref: React.RefObject<HTMLElement | null>\n  value: number\n  start: number\n  retarget: boolean\n  trigger: CountUpTrigger\n  reduceMotion: boolean\n  delay: number\n  duration: number\n}\n\n/** Plays once when seen. After that a new value only moves with `retarget`, and then from wherever the count has got to. */\nfunction useCountRun({ ref, value, start, retarget, trigger, reduceMotion, delay, duration }: CountRunInput) {\n  const count = useMotionValue(reduceMotion ? value : start)\n  const [resting, setResting] = useState(reduceMotion ? value : start)\n  const seen = useInView(ref, { once: true, margin: VIEW_MARGIN })\n  const ready = trigger === \"mount\" || seen\n  const played = useRef(false)\n  useEffect(() => {\n    if (!ready && !reduceMotion) return\n    const firstRun = !played.current\n    played.current = true\n    if (reduceMotion || (!firstRun && !retarget)) {\n      count.set(value)\n      setResting(value)\n      return\n    }\n    const controls = animate(count, value, {\n      ...SPRING_DEFAULT,\n      duration,\n      delay: firstRun ? delay : 0,\n      onComplete: () => setResting(value),\n    })\n    return () => controls.stop()\n    // A new delay or duration applies to the next run; it must not restart the one in progress.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [ready, value, retarget, reduceMotion, count])\n  return { count, resting }\n}\n\ninterface CountNumberProps extends CountFormatInput {\n  value: number\n  variant: CountUpVariant\n  retarget: boolean\n  from: number\n  max: number | undefined\n  trigger: CountUpTrigger\n  delay: number\n  duration: number\n  rootRef: React.RefObject<HTMLElement | null>\n}\n\nfunction CountNumber({ value, variant, retarget, from, max, trigger, delay, duration, rootRef, locale, formatOptions, prefix, suffix }: CountNumberProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const start = Number.isFinite(from) ? from : 0\n  const format = useMemo(() => createCountFormat(value, { locale, formatOptions, prefix, suffix }), [value, locale, formatOptions, prefix, suffix])\n  const { count, resting } = useCountRun({ ref: rootRef, value, start, retarget, trigger, reduceMotion, delay, duration })\n  // Without a max the line is measured against the first value, so a retarget never rescales what is already drawn.\n  const [firstValue] = useState(value)\n  const final = format.text(value)\n  const target = format.tokens(value)\n  const rolling = variant === \"odometer\" || variant === \"board\"\n  return (\n    <>\n      {/* Reels hold every digit from 0 to 9, so dragging across them would copy all of it. Over reels the real number is laid on top, unseen but selectable. */}\n      <span data-slot=\"count-up-text\" className={rolling ? \"absolute inset-0 overflow-clip text-transparent\" : \"sr-only select-none\"} suppressHydrationWarning>\n        {final}\n      </span>\n      {variant === \"plain\" ? <PlainFigure count={count} format={format} value={value} /> : null}\n      {variant === \"meter\" ? <MeterFigure count={count} format={format} value={value} from={start} top={max ?? firstValue} /> : null}\n      {rolling ? (\n        <CountReels shape={widerTokens(format.tokens(resting), target)} target={target} format={format} value={value} count={count} look={variant} />\n      ) : null}\n    </>\n  )\n}\n\ntype CountUpProps = Omit<React.ComponentProps<\"span\">, \"children\" | \"prefix\"> &\n  CountFormatInput & {\n    value: number\n    /** `plain` the number updates, `odometer` each digit rolls behind a clip, `board` each digit on its own tile, `meter` a thin line fills below. */\n    variant?: CountUpVariant\n    /** `retarget` animates to a new `value` from whatever is showing, and can be interrupted. Without it a new value appears at once. */\n    effect?: CountUpEffect\n    /** Where the first count starts. */\n    from?: number\n    /** `meter` only: the value of a full line. Without it the line ends full at the first value. */\n    max?: number\n    /** `view` plays once when first seen, `mount` right away. */\n    trigger?: CountUpTrigger\n    /** Seconds before the first count starts. */\n    delay?: number\n    /** Seconds one count takes. */\n    duration?: number\n    /** Read out instead of the dash when the value is not a finite number. */\n    unavailableLabel?: string\n  }\n\nfunction CountUp({\n  value,\n  variant = \"plain\",\n  effect,\n  from = 0,\n  max,\n  trigger = \"view\",\n  delay = 0,\n  duration = DEFAULT_DURATION_SECONDS,\n  unavailableLabel = \"Not available\",\n  locale,\n  formatOptions,\n  prefix,\n  suffix,\n  className,\n  ...props\n}: CountUpProps) {\n  const ref = useRef<HTMLSpanElement>(null)\n  const root = { ...props, ref, \"data-slot\": \"count-up\", \"data-variant\": variant, \"data-effect\": effect }\n  const layout = variant === \"meter\" ? \"grid\" : \"inline-grid\"\n  if (!Number.isFinite(value)) {\n    return (\n      <span {...root} data-unavailable=\"true\" className={cn(layout, \"tabular-nums\", className)}>\n        <span className=\"sr-only\">{unavailableLabel}</span>\n        <span aria-hidden=\"true\" data-slot=\"count-up-visual\">\n          {UNAVAILABLE_MARK}\n        </span>\n      </span>\n    )\n  }\n  return (\n    <span {...root} className={cn(\"relative whitespace-nowrap tabular-nums\", layout, className)}>\n      <CountNumber\n        rootRef={ref}\n        value={value}\n        variant={variant}\n        retarget={effect === \"retarget\"}\n        from={from}\n        max={max}\n        trigger={trigger}\n        delay={delay}\n        duration={duration}\n        locale={locale}\n        formatOptions={formatOptions}\n        prefix={prefix}\n        suffix={suffix}\n      />\n    </span>\n  )\n}\n\nexport { CountUp, type CountUpProps, type CountUpVariant, type CountUpEffect, type CountUpTrigger }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/count-up-figure.tsx",
      "content": "import { motion, useTransform, type MotionValue } from \"motion/react\"\nimport { meterFraction, type CountFormat } from \"@/registry/miseui/ui/count-up-math\"\n\ninterface FigureProps {\n  count: MotionValue<number>\n  format: CountFormat\n  value: number\n}\n\nfunction PlainFigure({ count, format, value }: FigureProps) {\n  const text = useTransform(count, (current) => format.text(current))\n  return (\n    // Stacked in one cell, right-aligned: the final text holds the width, and 99 turning into 100 grows to the left with the ones in place.\n    <span aria-hidden=\"true\" data-slot=\"count-up-visual\" className=\"inline-grid justify-items-end whitespace-nowrap *:[grid-area:1/1]\">\n      <span data-slot=\"count-up-reserve\" className=\"invisible\" suppressHydrationWarning>\n        {format.text(value)}\n      </span>\n      <motion.span data-slot=\"count-up-value\" suppressHydrationWarning>\n        {text}\n      </motion.span>\n    </span>\n  )\n}\n\ninterface MeterProps extends FigureProps {\n  from: number\n  /** The value of a full line. */\n  top: number\n}\n\nfunction MeterFigure({ count, format, value, from, top }: MeterProps) {\n  const scaleX = useTransform(count, (current) => meterFraction(current, from, top))\n  return (\n    <span className=\"grid grid-cols-[minmax(0,1fr)] gap-2\">\n      <span className=\"justify-self-start\">\n        <PlainFigure count={count} format={format} value={value} />\n      </span>\n      <span aria-hidden=\"true\" data-slot=\"count-up-track\" className=\"block h-0.5 overflow-clip rounded-full bg-border\">\n        <motion.span data-slot=\"count-up-fill\" className=\"block h-full origin-left rounded-full bg-mise rtl:origin-right\" style={{ scaleX }} />\n      </span>\n    </span>\n  )\n}\n\nexport { MeterFigure, PlainFigure }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/count-up-reels.tsx",
      "content": "import { useLayoutEffect, useRef, useState, type RefObject } from \"react\"\nimport type { MotionValue } from \"motion/react\"\nimport type { CountFormat, CountToken } from \"@/registry/miseui/ui/count-up-math\"\nimport { REEL_CELLS, digitPosition, reelProgress, reelTransform, rollDistance, targetDigit } from \"@/registry/miseui/ui/count-up-roll\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ReelLook = \"odometer\" | \"board\"\n\nconst STRIP_SELECTOR = \"[data-slot='count-up-strip']\"\n\nconst ROW_CLASS: Record<ReelLook, string> = {\n  odometer: \"items-baseline\",\n  board: \"items-center gap-[0.1em]\",\n}\n\nconst REEL_CLASS: Record<ReelLook, string> = {\n  odometer: \"\",\n  board: \"rounded-[0.18em] bg-muted ring-1 ring-inset ring-border\",\n}\n\n/** The padding sits on every cell, not the tile, so a digit rolls right up to the tile edge instead of vanishing inside it. */\nconst CELL_CLASS: Record<ReelLook, string> = {\n  odometer: \"\",\n  board: \"px-[0.22em] py-[0.08em]\",\n}\n\nconst MARK_CLASS: Record<ReelLook, string> = {\n  odometer: \"\",\n  board: \"text-muted-foreground\",\n}\n\n/** Every strip follows the one count, through one subscription per component rather than one motion value per column. */\nfunction useReelWrites(rowRef: RefObject<HTMLElement | null>, count: MotionValue<number>, value: number, target: readonly CountToken[], scale: number, shapeKey: string) {\n  const positions = useRef<ReadonlyMap<number, number>>(new Map())\n  useLayoutEffect(() => {\n    const strips = Array.from(rowRef.current?.querySelectorAll<HTMLElement>(STRIP_SELECTOR) ?? [])\n    const from = count.get()\n    const plan = strips.map((element) => {\n      const place = Number(element.dataset.place)\n      const start = positions.current.get(place) ?? digitPosition(from, place, scale)\n      const distance = rollDistance({ start, target: targetDigit(target, place), from, to: value, place, scale })\n      return { element, place, start, distance }\n    })\n    const write = (current: number) => {\n      const progress = reelProgress(from, value, current)\n      const placed = plan.map(({ element, place, start, distance }) => {\n        const position = start + distance * progress\n        element.style.transform = reelTransform(position)\n        return [place, position] as const\n      })\n      positions.current = new Map(placed)\n    }\n    write(from)\n    return count.on(\"change\", write)\n    // The tokens are rebuilt with the format; `value` and `shapeKey` are what actually change a plan.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [rowRef, count, value, scale, shapeKey])\n}\n\ninterface ReelProps {\n  place: number\n  glyphs: readonly string[]\n  count: MotionValue<number>\n  scale: number\n  look: ReelLook\n}\n\nfunction Reel({ place, glyphs, count, scale, look }: ReelProps) {\n  // Set once and never again: React must not repaint a transform the count is writing. A new reel starts on the shown digit.\n  const [initial] = useState(() => reelTransform(digitPosition(count.get(), place, scale)))\n  const cells = Array.from({ length: REEL_CELLS }, (_, cell) => glyphs[cell % 10])\n  return (\n    <span data-slot=\"count-up-reel\" className={cn(\"relative inline-block overflow-clip\", REEL_CLASS[look])}>\n      {/* Holds the width, the height and the baseline; overflow-clip leaves the baseline where it is, unlike hidden. */}\n      <span className={cn(\"invisible block text-center\", CELL_CLASS[look])}>{glyphs[0]}</span>\n      <span data-slot=\"count-up-strip\" data-place={place} className=\"absolute inset-x-0 top-0 block will-change-transform\" style={{ transform: initial }}>\n        {cells.map((glyph, cell) => (\n          <span key={cell} className={cn(\"block text-center\", CELL_CLASS[look])}>\n            {glyph}\n          </span>\n        ))}\n      </span>\n    </span>\n  )\n}\n\ninterface CountReelsProps {\n  /** The digits and marks on screen, which may be wider than the target while a number shrinks. */\n  shape: readonly CountToken[]\n  target: readonly CountToken[]\n  format: CountFormat\n  value: number\n  count: MotionValue<number>\n  look: ReelLook\n}\n\nfunction CountReels({ shape, target, format, value, count, look }: CountReelsProps) {\n  // Its own ref: a layout effect runs before a parent's ref is attached, so the root is still null on first mount.\n  const rowRef = useRef<HTMLSpanElement>(null)\n  const shapeKey = shape.map((token) => token.key).join(\"|\")\n  useReelWrites(rowRef, count, value, target, format.scale, shapeKey)\n  return (\n    <span ref={rowRef} aria-hidden=\"true\" data-slot=\"count-up-visual\" className={cn(\"pointer-events-none inline-flex select-none\", ROW_CLASS[look])}>\n      {shape.map((token) =>\n        token.kind === \"digit\" ? (\n          <Reel key={token.key} place={token.place} glyphs={format.glyphs} count={count} scale={format.scale} look={look} />\n        ) : (\n          <span key={token.key} data-slot=\"count-up-mark\" className={cn(\"whitespace-pre\", MARK_CLASS[look])}>\n            {token.text}\n          </span>\n        ),\n      )}\n    </span>\n  )\n}\n\nexport { CountReels, type ReelLook }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/count-up-math.ts",
      "content": "/** Shown instead of a number when the value is NaN or infinite. An en dash, so it reads as \"none\" and not as a minus. */\nconst UNAVAILABLE_MARK = \"–\"\n\nconst MAX_FRACTION_DIGITS = 20\n\ninterface CountFormatInput {\n  /** Leave unset to follow the viewer. A server-rendered page should pass one, or its first paint can differ from the client. */\n  locale?: Intl.LocalesArgument\n  formatOptions?: Intl.NumberFormatOptions\n  prefix?: string\n  suffix?: string\n}\n\ninterface DigitToken {\n  kind: \"digit\"\n  key: string\n  /** Power of ten this digit stands for: 0 for ones, 1 for tens, -1 for tenths. */\n  place: number\n  digit: number\n}\n\ninterface MarkToken {\n  kind: \"mark\"\n  key: string\n  text: string\n}\n\ntype CountToken = DigitToken | MarkToken\n\n/** Decimal places of a number as written, including the ones hidden in exponent form like 1.5e-7. */\nfunction decimalsOf(value: number) {\n  if (!Number.isFinite(value)) return 0\n  const [mantissa, exponent = \"0\"] = String(Math.abs(value)).split(\"e\")\n  const written = mantissa.split(\".\")[1]?.length ?? 0\n  return Math.min(MAX_FRACTION_DIGITS, Math.max(0, written - Number(exponent)))\n}\n\n/** Intl has its own good answer for these, so a fixed fraction length would only fight it. */\nfunction leavesDigitsToIntl(options: Intl.NumberFormatOptions) {\n  return (\n    options.notation === \"compact\" ||\n    options.style === \"currency\" ||\n    options.minimumFractionDigits !== undefined ||\n    options.maximumFractionDigits !== undefined ||\n    options.minimumSignificantDigits !== undefined ||\n    options.maximumSignificantDigits !== undefined\n  )\n}\n\n/** Holds the number of decimals of the final value through the whole count, so the text never changes length on the way. */\nfunction resolveFormatOptions(value: number, options: Intl.NumberFormatOptions = {}): Intl.NumberFormatOptions {\n  if (leavesDigitsToIntl(options)) return options\n  const written = decimalsOf(value)\n  const decimals = options.style === \"percent\" ? Math.max(0, written - 2) : written\n  return { ...options, minimumFractionDigits: decimals, maximumFractionDigits: decimals }\n}\n\n/** The ten digit glyphs of this locale, in value order. Arabic-Indic and other numbering systems roll as well as Latin. */\nfunction digitGlyphs(locale?: Intl.LocalesArgument, options: Intl.NumberFormatOptions = {}): readonly string[] {\n  const format = new Intl.NumberFormat(locale, { numberingSystem: options.numberingSystem, useGrouping: false, maximumFractionDigits: 0 })\n  return Array.from({ length: 10 }, (_, digit) => format.format(digit))\n}\n\nconst countIntegerDigits = (parts: readonly Intl.NumberFormatPart[]) =>\n  parts.filter((part) => part.type === \"integer\").reduce((sum, part) => sum + Array.from(part.value).length, 0)\n\ninterface TokenWalk {\n  tokens: readonly CountToken[]\n  /** Next place to hand out, counting down through the integer and on into the fraction. */\n  place: number\n}\n\nfunction appendDigits(walk: TokenWalk, text: string, glyphs: readonly string[]): TokenWalk {\n  const digits = Array.from(text)\n  const tokens = digits.map<CountToken>((glyph, offset) => {\n    const place = walk.place - offset\n    return { kind: \"digit\", key: `digit:${place}`, place, digit: Math.max(0, glyphs.indexOf(glyph)) }\n  })\n  return { tokens: [...walk.tokens, ...tokens], place: walk.place - digits.length }\n}\n\n/** Splits a number on formatToParts, so group marks, minus signs and compact units like 1.5천 land where the locale puts them. */\nfunction toTokens(parts: readonly Intl.NumberFormatPart[], glyphs: readonly string[]): readonly CountToken[] {\n  const walk = parts.reduce<TokenWalk>(\n    (state, part, position) => {\n      if (part.type === \"integer\" || part.type === \"fraction\") return appendDigits(state, part.value, glyphs)\n      // A group mark is keyed by the digit beside it, so its reel keeps its identity when the number grows on the left.\n      const anchor = part.type === \"group\" || part.type === \"decimal\" ? `${state.place}` : `${position}`\n      return { ...state, tokens: [...state.tokens, { kind: \"mark\", key: `${part.type}:${anchor}`, text: part.value }] }\n    },\n    { tokens: [], place: countIntegerDigits(parts) - 1 },\n  )\n  return walk.tokens\n}\n\nconst isDigit = (token: CountToken): token is DigitToken => token.kind === \"digit\"\n\n/** The number the digits spell, ignoring units: 1.5 for 1.5천, 72 for 72%. */\nfunction spelledValue(tokens: readonly CountToken[]) {\n  return tokens.filter(isDigit).reduce((sum, token) => sum + token.digit * 10 ** token.place, 0)\n}\n\ninterface CountFormat {\n  text: (current: number) => string\n  tokens: (current: number) => readonly CountToken[]\n  glyphs: readonly string[]\n  /** What one unit of the spelled digits is worth in the real value. 1000 for 1.5천, 0.01 for 72%. */\n  scale: number\n}\n\nfunction createCountFormat(value: number, { locale, formatOptions, prefix = \"\", suffix = \"\" }: CountFormatInput = {}): CountFormat {\n  const format = new Intl.NumberFormat(locale, resolveFormatOptions(value, formatOptions))\n  const glyphs = digitGlyphs(locale, formatOptions)\n  // A count passing through -0.2 must not print \"-0\" for a frame.\n  const signless = (current: number) => (current < 0 && spelledValue(toTokens(format.formatToParts(current), glyphs)) === 0 ? 0 : current)\n  const tokens = (current: number): readonly CountToken[] => [\n    ...(prefix ? [{ kind: \"mark\" as const, key: \"prefix\", text: prefix }] : []),\n    ...toTokens(format.formatToParts(signless(current)), glyphs),\n    ...(suffix ? [{ kind: \"mark\" as const, key: \"suffix\", text: suffix }] : []),\n  ]\n  const spelled = spelledValue(tokens(value))\n  return {\n    text: (current) => `${prefix}${format.format(signless(current))}${suffix}`,\n    tokens,\n    glyphs,\n    scale: spelled > 0 ? Math.abs(value) / spelled : 1,\n  }\n}\n\n/** While a count shrinks from 100 to 99 the third reel still has to show the 1 it is rolling away from. */\nfunction widerTokens(resting: readonly CountToken[], target: readonly CountToken[]) {\n  return resting.filter(isDigit).length > target.filter(isDigit).length ? resting : target\n}\n\n/** How full the meter line is. `top` is the full-line value: the owner's max, or the first value if there is none. */\nfunction meterFraction(current: number, from: number, top: number) {\n  if (top === from) return 1\n  return Math.min(1, Math.max(0, (current - from) / (top - from)))\n}\n\nexport {\n  UNAVAILABLE_MARK,\n  createCountFormat,\n  decimalsOf,\n  digitGlyphs,\n  isDigit,\n  meterFraction,\n  resolveFormatOptions,\n  toTokens,\n  widerTokens,\n  type CountFormat,\n  type CountFormatInput,\n  type CountToken,\n  type DigitToken,\n  type MarkToken,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/count-up-roll.ts",
      "content": "import { isDigit, type CountToken } from \"@/registry/miseui/ui/count-up-math\"\n\n/** Digits 0 to 9 plus a second 0, so 9 rolls forward into 0 without a seam. */\nconst REEL_CELLS = 11\n/** A low digit that would pass hundreds of steps turns once at most. Faster than that it is only a blur. */\nconst MAX_EXTRA_TURNS = 1\n\nfunction wrapDigit(position: number) {\n  return ((position % 10) + 10) % 10\n}\n\n/** Where a reel rests for a real value, in whole digit steps. */\nfunction digitPosition(current: number, place: number, scale: number) {\n  return wrapDigit(Math.floor(Math.abs(current) / scale / 10 ** place + 1e-9))\n}\n\n/** Digit each reel must end on for this value. Reels the number no longer reaches end on 0. */\nfunction targetDigit(tokens: readonly CountToken[], place: number) {\n  return tokens.filter(isDigit).find((token) => token.place === place)?.digit ?? 0\n}\n\ninterface RollInput {\n  /** Where the reel is now, perhaps halfway between two digits after an interruption. */\n  start: number\n  target: number\n  from: number\n  to: number\n  place: number\n  scale: number\n}\n\n/** Signed distance for one reel. High places travel what they really pass; low ones are capped so they stay readable. */\nfunction rollDistance({ start, target, from, to, place, scale }: RollInput) {\n  const direction = Math.abs(to) >= Math.abs(from) ? 1 : -1\n  const unit = scale * 10 ** place\n  const passed = Math.abs(Math.floor(Math.abs(to) / unit + 1e-9) - Math.floor(Math.abs(from) / unit + 1e-9))\n  const turns = Math.min(MAX_EXTRA_TURNS, Math.floor(passed / 10))\n  return direction * (wrapDigit((target - start) * direction) + turns * 10)\n}\n\n/** How far along the current run the count is. Reels read this, so one motion value drives every column. */\nfunction reelProgress(from: number, to: number, current: number) {\n  if (from === to) return 1\n  return Math.min(1, Math.max(0, (current - from) / (to - from)))\n}\n\n/** The strip is REEL_CELLS tall, so a percentage of itself moves it by whole digits at any font size. */\nfunction reelTransform(position: number) {\n  return `translateY(${(-(wrapDigit(position) / REEL_CELLS) * 100).toFixed(4)}%)`\n}\n\nexport { REEL_CELLS, digitPosition, reelProgress, reelTransform, rollDistance, targetDigit }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}