Digit Roll
A number field with a typographic face. The value is rendered as a row of fixed width slots, each one tall enough for a single line, and only the characters that change roll out and in. Typing still works: a transparent input sits over the whole field, so the digits you see are decorative and the value you edit is the real control underneath.
The rolling direction follows the value. Increasing sends the old digit up and the new one in from below; decreasing does the opposite, and a digit that does not change stays still. That detail is what makes a multi-digit number read as one counter instead of several independent animations.
Usage
prefix and suffix are rendered inside the field and are stripped when a typed
value is parsed. precision rounds both typed and stepped values, and step
drives the arrow keys, the stepper buttons and the scrub distance.
<template>
<div class="flex w-full justify-center py-6">
<MDigitRoll
class="w-52"
:default-value="12"
label="Tip"
:max="100"
:min="0"
:precision="2"
prefix="$"
:step="0.5"
stepper
/>
</div>
</template>
Drag horizontally anywhere on the field to scrub the value. The gesture only starts after a few pixels of movement, so a click still places the caret.
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | number | - | Controlled value. Leave it out to let the component keep its own. |
defaultValue | number | 0 | Starting value in uncontrolled usage. |
min | number | -Infinity | Smallest allowed value. Also the target of the Home key. |
max | number | Infinity | Largest allowed value. Also the target of the End key. |
step | number | 1 | Increment used by the arrow keys, the stepper buttons and scrubbing. |
precision | number | - | Decimal places the value is rounded to, and the digits shown after the decimal separator. |
format | Intl.NumberFormatOptions | - | Formatting options merged over the precision defaults, for currency, percent or unit suffixes. |
prefix | string | '' | Static text before the value. |
suffix | string | '' | Static text after the value. |
label | string | - | Visible label, also used as the accessible name. |
stepper | boolean | false | Render the minus and plus buttons inside the field. |
scrub | boolean | true | Allow horizontal dragging to change the value. |
clampOnBlur | boolean | true | Clamp a typed out-of-range value instead of rejecting it on commit. |
disabled | boolean | false | Disable typing, stepping and scrubbing. |
class | string | - | Extra classes for the outer wrapper. |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | (value: number) => void | Fired whenever the committed value changes, whether it came from typing,
keys, the stepper or a scrub. Pair it with modelValue for controlled usage. |
Accessibility
- The real element is an
<input>withrole="spinbutton",aria-valuenow,aria-valuemin,aria-valuemaxand a formattedaria-valuetext, so the spoken value matches what is on screen. - The visible digits are
aria-hidden; they are a rendering of the value, not a second source of truth. - Up and Down step, Page Up and Page Down jump ten steps, Shift multiplies the step by ten, Alt divides it, and Home and End jump to the finite bounds.
- The label is tied to the input with
for/id; without a label the input falls back to the accessible nameNumber input. - An invalid commit sets
aria-invalidand shakes the field, so the error is not conveyed by the red border alone. Under reduced motion the shake is skipped. - The stepper buttons are real buttons with
aria-labels and become disabled at the bounds instead of silently doing nothing.
Performance
- Only the characters that changed are mounted and animated. Unchanged digits
keep their node, so a value like
1.10to1.11animates one slot. Intl.NumberFormatis built once per precision and format options, not on every render or keypress.- Scrubbing quantises the pointer to eight-pixel steps and only commits when the quantised value changes, so a drag does not fire a cascade of identical updates.
- The roll itself is a short spring on
yandopacity; there is no layout measurement and no per-frame JavaScript.