{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rolling-number-stepper",
  "title": "Rolling Number Stepper",
  "author": "henriquegpb <https://github.com/henriquegpb>",
  "description": "A minus and plus stepper where only the digits that change roll, up on the way up and down on the way down, and a digit that appears or disappears opens or closes its column; holding a button keeps stepping.",
  "files": [
    {
      "path": "registry/microkit/rolling-number-stepper.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type MouseEvent, type PointerEvent } from \"react\";\n\nconst MIN = 0;\nconst MAX = 99;\nconst START = 8;\nconst EASE = \"cubic-bezier(.22, 1, .36, 1)\";\n\n// Ref callbacks rather than effects: each one runs once, the moment its digit or column is mounted.\nconst roll = (node: HTMLElement | null) => {\n  if (!node) return;\n  // Strict Mode attaches refs twice in development; starting over keeps the second pass from stacking on the first.\n  node.getAnimations().forEach((animation) => animation.cancel());\n  const direction = Number(node.dataset.direction);\n  const leaving = node.dataset.roll === \"out\";\n  const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  // A step up pushes the old digit out of the top and brings the new one in from below; a step down reverses it.\n  const away = `translateY(${direction * -100}%)`;\n  const toward = `translateY(${direction * 100}%)`;\n  node.animate(\n    leaving\n      ? [{ transform: \"none\", opacity: 1 }, { transform: away, opacity: 0 }]\n      : [{ transform: toward, opacity: 0 }, { transform: \"none\", opacity: 1 }],\n    { duration: reduce ? 0 : 450, easing: EASE, fill: leaving ? \"forwards\" : \"none\" },\n  );\n};\n\n// A digit that appears or disappears (9 to 10, 10 to 9) opens or closes its column, so the number re-centres smoothly.\nconst resize = (node: HTMLElement | null) => {\n  if (!node) return;\n  // Measured with any earlier pass cancelled, or a column caught mid-collapse would open to nothing.\n  node.getAnimations().forEach((animation) => animation.cancel());\n  const width = `${node.offsetWidth}px`;\n  const leaving = node.dataset.column === \"out\";\n  const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  node.animate(leaving ? [{ width }, { width: \"0px\" }] : [{ width: \"0px\" }, { width }], {\n    duration: reduce ? 0 : 450,\n    easing: EASE,\n    fill: leaving ? \"forwards\" : \"none\",\n  });\n};\n\ntype Change = { value: number; previous: number; direction: number; tick: number };\n\nfunction RollingDigits({ value, previous, direction, tick }: Change) {\n  const now = String(value);\n  const was = String(previous);\n  const length = Math.max(now.length, was.length);\n\n  return (\n    <span className=\"pointer-events-none inline-flex select-none justify-self-center [grid-area:1/1]\" aria-hidden=\"true\">\n      {Array.from({ length }, (_, index) => {\n        // Columns are matched from the right, so the ones digit always rolls over the ones digit.\n        const place = length - 1 - index;\n        const digit = now[now.length - 1 - place];\n        const old = was[was.length - 1 - place];\n        if (digit === old) {\n          return (\n            <span key={place} className=\"inline-grid overflow-hidden\">\n              <span className=\"[grid-area:1/1]\">{digit}</span>\n            </span>\n          );\n        }\n        const column = digit === undefined ? \"out\" : old === undefined ? \"in\" : undefined;\n        return (\n          // Keyed by the step, so every change mounts fresh digits and their animation plays again.\n          <span\n            key={`${place}-${tick}`}\n            ref={column ? resize : undefined}\n            className=\"inline-grid overflow-hidden\"\n            data-column={column}\n          >\n            {old !== undefined && (\n              <span ref={roll} className=\"[grid-area:1/1]\" data-roll=\"out\" data-direction={direction}>\n                {old}\n              </span>\n            )}\n            {digit !== undefined && (\n              <span ref={roll} className=\"[grid-area:1/1]\" data-roll=\"in\" data-direction={direction}>\n                {digit}\n              </span>\n            )}\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nexport function RollingNumberStepper() {\n  const [change, setChange] = useState<Change>({ value: START, previous: START, direction: 1, tick: 0 });\n  const valueRef = useRef(START);\n  const hold = useRef({ delay: 0, repeat: 0 });\n\n  useEffect(() => {\n    const timers = hold.current;\n    return () => {\n      clearTimeout(timers.delay);\n      clearInterval(timers.repeat);\n    };\n  }, []);\n\n  const release = () => {\n    clearTimeout(hold.current.delay);\n    clearInterval(hold.current.repeat);\n  };\n\n  const nudge = (direction: number) => {\n    const previous = valueRef.current;\n    const value = Math.min(MAX, Math.max(MIN, previous + direction));\n    // A disabled button never hears the pointer come up, so reaching the limit ends the hold itself.\n    if (value === previous) return release();\n    valueRef.current = value;\n    setChange((current) => ({ value, previous, direction, tick: current.tick + 1 }));\n  };\n\n  // Pressing steps once; holding on keeps stepping until the pointer lets go.\n  const press = (event: PointerEvent<HTMLButtonElement>, direction: number) => {\n    if (event.button !== 0) return;\n    release();\n    nudge(direction);\n    hold.current.delay = window.setTimeout(() => {\n      hold.current.repeat = window.setInterval(() => nudge(direction), 120);\n    }, 400);\n  };\n\n  // Pointer presses already stepped on the way down, so only a keyboard click (detail 0) steps here.\n  const click = (event: MouseEvent<HTMLButtonElement>, direction: number) => {\n    if (event.detail === 0) nudge(direction);\n  };\n\n  return (\n    <div\n      className=\"inline-flex items-center gap-[18px] text-[color:light-dark(#14181e,#f2f4f6)]\"\n      role=\"group\" aria-label=\"Tickets\">\n      <button\n        type=\"button\"\n        className=\"m-0 grid size-[44px] shrink-0 cursor-pointer touch-manipulation select-none place-items-center rounded-full border-0 bg-[light-dark(#f0f2f5,#1b1e23)] p-0 text-inherit transition-[background-color,transform,opacity] duration-200 ease-[cubic-bezier(.32,.72,0,1)] [-webkit-touch-callout:none] enabled:hover:bg-[light-dark(#e6e9ee,#24282e)] enabled:active:scale-[.92] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff] disabled:cursor-default disabled:opacity-[.35] motion-reduce:transition-[background-color,opacity]\"\n        aria-label=\"Remove a ticket\"\n        onPointerDown={(event) => press(event, -1)}\n        onPointerUp={release}\n        onPointerLeave={release}\n        onPointerCancel={release}\n        onClick={(event) => click(event, -1)}\n        disabled={change.value <= MIN}\n      >\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden=\"true\">\n          <path d=\"M5 12h14\" />\n        </svg>\n      </button>\n      <div className=\"grid min-w-0 justify-items-center\">\n        <span className=\"inline-grid whitespace-nowrap text-[32px] font-semibold leading-none tracking-[-.02em] tabular-nums\">\n          {/* The widest value sizes the box, so the buttons never move while digits come and go. */}\n          <span className=\"invisible justify-self-center [grid-area:1/1]\" aria-hidden=\"true\">\n            {MAX}\n          </span>\n          {/* The settled value is what screen readers and copy-paste get, but it is never painted. */}\n          <output className=\"justify-self-center text-transparent [grid-area:1/1]\" aria-live=\"polite\">\n            {change.value}\n          </output>\n          <RollingDigits {...change} />\n        </span>\n        <span className=\"mt-[6px] text-[11px] font-medium leading-none text-[color:light-dark(#69727f,#868d97)]\">Tickets</span>\n      </div>\n      <button\n        type=\"button\"\n        className=\"m-0 grid size-[44px] shrink-0 cursor-pointer touch-manipulation select-none place-items-center rounded-full border-0 bg-[light-dark(#f0f2f5,#1b1e23)] p-0 text-inherit transition-[background-color,transform,opacity] duration-200 ease-[cubic-bezier(.32,.72,0,1)] [-webkit-touch-callout:none] enabled:hover:bg-[light-dark(#e6e9ee,#24282e)] enabled:active:scale-[.92] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff] disabled:cursor-default disabled:opacity-[.35] motion-reduce:transition-[background-color,opacity]\"\n        aria-label=\"Add a ticket\"\n        onPointerDown={(event) => press(event, 1)}\n        onPointerUp={release}\n        onPointerLeave={release}\n        onPointerCancel={release}\n        onClick={(event) => click(event, 1)}\n        disabled={change.value >= MAX}\n      >\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden=\"true\">\n          <path d=\"M5 12h14M12 5v14\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/microkit/rolling-number-stepper.tsx"
    }
  ],
  "docs": "Live preview and the CSS, JavaScript and Tailwind variants: https://microkit.co/components/rolling-number-stepper",
  "categories": [
    "input",
    "click"
  ],
  "type": "registry:component"
}