Forms

Animated Number Input

A number input whose digits roll up and down as the value changes.

A number input whose digits roll. Each character of the displayed value sits in its own cell, and when a character changes it enters and exits with a vertical offset in the direction of the change — up when the number grew, down when it shrank.

The visible digits are not the input's text. A real UInputNumber sits underneath with its text made transparent and only its caret left visible, and a aria-hidden, pointer-events-none overlay draws the formatted, animated value on top. That is what lets a $12,480.00 be formatted and animated while the field underneath stays a plain editable text input.

The input keeps a raw copy of what you are typing and shows that instead of the formatted value while the field has focus, so a partially typed number is never reformatted under the caret.

Usage

Bind v-model to a number. Use formatOptions for anything Intl.NumberFormat accepts — currency, compact notation, locale grouping — and min/max to clamp the field. Setting decrement="false" moves the number left so it clears the increment button on its own.

4
$2,500
Projected monthly total$10,000
<template>
  <div class="flex w-full max-w-md flex-col gap-5">
    <MAnimatedNumberInput
      v-model="seats"
      label="Seats"
      :min="1"
      :max="12"
      :step="1"
    />

    <MAnimatedNumberInput
      v-model="budget"
      label="Monthly budget"
      :decrement="false"
      :min="0"
      :step="500"
      :format-options="currency"
    />

    <div class="flex items-center justify-between rounded-lg bg-elevated px-4 py-3 text-sm">
      <span class="text-muted">Projected monthly total</span>
      <span class="font-semibold tabular-nums text-highlighted">{{ total }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
const seats = ref(4)
const budget = ref(2500)

const currency = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }

const total = computed(() => new Intl.NumberFormat('en-US', currency).format(seats.value * budget.value))
</script>

Customize

0

Customize

Default Value0
Disabled
Format Options
Id
Increment
Decrement
Label
MaxInfinity
Min-Infinity
Name
Placeholder
Required
Step1
Model Value

Props

Prop Type Default Description
modelValuenumber-The current value. While undefined the component uses its internal copy seeded from defaultValue; passing a value switches it to controlled mode. The digit direction is derived from this number's changes.
defaultValuenumber0Seeds the uncontrolled value, and is also forwarded to UInputNumber as its own default-value. Only read during setup.
labelstring''Rendered as a real <label :for> above the field, and only rendered at all when non-empty — there is no floating or in-field label here.
formatOptionsIntl.NumberFormatOptions-Forwarded into new Intl.NumberFormat. Accepts anything Intl does, so { style: 'currency', currency: 'USD' } and { notation: 'compact' } both work. The formatter is rebuilt when this object's identity changes.
minnumber-InfinityLower bound. An infinite value is passed to UInputNumber as undefined, so the default really is unbounded rather than a literal Infinity.
maxnumberInfinityUpper bound, with the same infinite-value handling as min.
stepnumber1Increment applied by the stepper buttons and by arrow keys.
incrementboolean | objecttrueForwarded to UInputNumber. false removes the increment button; an object is spread onto the button, so { class: 'text-primary' } works. When decrement is false the number left-aligns to clear this button.
decrementboolean | objecttrueForwarded to UInputNumber, with the same object form as increment. Setting it to false switches the overlay from centred to left-aligned padding.
disabledbooleanfalseForwarded to UInputNumber, which disables the input and both stepper buttons. The overlay stays visible and keeps showing the current value.
idstring-Forwarded as the field's id. When omitted the component derives one from useId(), and the rendered <label> points at it.
namestring-Forwarded, so the field participates in native form submission.
placeholderstring-Forwarded to the underlying input, and therefore visible through the transparent text layer.
requiredbooleanfalseForwarded to UInputNumber.

Events

Event Payload Description
update:modelValue(value: number) => voidEmitted whenever UInputNumber reports a new value. Also clears the editing flag, so a change made with the stepper buttons reformats the display immediately.
blur(event: FocusEvent) => voidForwarded from UInputNumber's input blur.
change(event: Event) => voidForwarded from UInputNumber. A synthetic Event whose target.value is the formatted value.

UI

This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.animatedNumberInput in app.config.ts.

Prop Type Default Description
uiobject-Per-slot classes merged over the theme defaults.
Slot Props Description
root-inline-flex flex-col gap-1.5
label-text-sm font-medium text-highlighted
field-animated-number-input-hide relative
input-w-full
overlay-pointer-events-none absolute inset-0 flex select-none items-center text-base/5 text-highlighted md:text-sm
digit-relative inline-block h-[1.2em] w-[0.62em] overflow-hidden text-center align-bottom tabular-nums
char-absolute inset-0 flex items-center justify-center

Accessibility

The component renders a genuine <label :for> bound to a generated id, so the label is associated by the platform rather than by aria-label. Below it, UInputNumber supplies the spinbutton semantics, aria-valuemin, aria-valuemax, aria-valuenow, and aria-labeled increment and decrement buttons.

The overlay that draws the visible digits is aria-hidden and pointer-events-none, so the number is announced once, from the real input. The input's color is forced transparent while its caret-color is set to the highlighted foreground, which is why the caret is still visible while the digits appear to be painted by the component.

Two consequences of that arrangement are worth stating plainly. Because the overlay is select-none and the underlying text is transparent, the displayed value cannot be selected or copied by dragging over it. And since the real input is a text field rather than type="number", the digits the user sees are the unformatted raw text — in-page find, autofill and password managers all see the raw value, not the currency-formatted one.

useReducedMotion() is honoured. Under reduce the spring collapses to { duration: 0 } and the enter/exit states drop their y offset, so digits swap in place instead of rolling.

Performance

  • The overlay renders one cell per character and each cell holds a single motion.span inside AnimatePresence mode="popLayout". A long currency value is a handful of absolutely-positioned spans, and only the characters that actually changed are mounted and unmounted.
  • Movement is y, a transform, so the roll never triggers layout. The only layout-adjacent work is the per-character wrapper sizing, which is static.
  • Four listeners are attached: capture-phase input and pointerdown on the field, and pointerup and pointercancel on window. All four are removed on unmount. No timers and no requestAnimationFrame.
  • A pressFirstUpdate guard means a held stepper button registers only its first update. Without it, key repeat would push a stream of values and roll the digits faster than they can animate.
  • A watcher on the value sets the roll direction, so the offset is correct on the first frame of the change rather than being corrected a frame later.
  • While the field has focus, the overlay shows the raw input text captured by the capture-phase input listener, which is what stops a partially typed number from being reformatted mid-keystroke.
  • The Intl.NumberFormat instance is held in a computed, so it is only reconstructed when formatOptions changes identity.
Each character's cell is keyed by its index and the animated span inside is keyed by the character, so AnimatePresence diffs per position. Going from 2500 to 3000 rolls only the leading digit and leaves the rest of the number perfectly still — the animation is local to the characters that changed, not to the value as a whole.
Copyright © 2026