Skip to content

Money Input

Currency prefix, live separators and rolling digits (IDR/USD).

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/money-input.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/money-input/money-input.tsx
    "use client";
    
    import { useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ComponentProps } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    export interface MoneyInputProps
      extends Omit<ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type" | "size" | "max" | "prefix"> {
      /** Controlled amount. null means empty. */
      value?: number | null;
      /** Initial amount when uncontrolled. @default null */
      defaultValue?: number | null;
      /** Called with the parsed amount, or null when the field is empty. */
      onValueChange?: (value: number | null) => void;
      /** ISO 4217 currency code. @default "USD" */
      currency?: string;
      /** BCP 47 locale for the separators and the symbol position. @default "en-US" */
      locale?: string;
      /** Digits allowed after the decimal separator. @default the currency's minor units (0 for IDR) */
      fractionDigits?: number;
      /** Largest accepted amount; keystrokes that would exceed it are ignored. */
      max?: number;
      /** Field size. "lg" suits a hero amount. @default "md" */
      size?: "md" | "lg";
      /** Classes for the outer field. */
      className?: string;
    }
    
    // Currencies whose everyday amounts have no minor units, even though ISO 4217 lists two.
    const WHOLE_UNIT_CURRENCIES = new Set(["IDR"]);
    
    interface MoneyFormat {
      locale: string;
      group: string;
      decimal: string;
      symbol: string;
      symbolFirst: boolean;
      fractionDigits: number;
    }
    
    function getFormat(locale: string, currency: string, fractionDigits?: number): MoneyFormat {
      const formatter = new Intl.NumberFormat(locale, { style: "currency", currency, numberingSystem: "latn" });
      const parts = formatter.formatToParts(1234567.5);
      // Separators come from a plain number format: a zero-decimal currency (e.g. IDR in some
      // engines) has no decimal part, and guessing one can collide with the group separator.
      const numberParts = new Intl.NumberFormat(locale, { numberingSystem: "latn" }).formatToParts(1234567.5);
      const numberPart = (type: Intl.NumberFormatPartTypes) => numberParts.find((p) => p.type === type)?.value;
      return {
        locale,
        group: numberPart("group") ?? ",",
        decimal: numberPart("decimal") ?? ".",
        symbol: parts.find((p) => p.type === "currency")?.value ?? currency,
        symbolFirst: parts.findIndex((p) => p.type === "currency") < parts.findIndex((p) => p.type === "integer"),
        fractionDigits:
          fractionDigits ??
          (WHOLE_UNIT_CURRENCIES.has(currency.toUpperCase()) ? 0 : (formatter.resolvedOptions().maximumFractionDigits ?? 2)),
      };
    }
    
    const isDigit = (ch: string) => ch >= "0" && ch <= "9";
    const isDecimalKey = (ch: string, f: MoneyFormat) => ch === f.decimal || (ch === "." && f.group !== ".");
    
    /** Raw keystrokes → canonical "1234.5" (digits, optional "." and fraction). */
    function sanitize(raw: string, f: MoneyFormat) {
      let integer = "";
      let fraction = "";
      let hasDecimal = false;
      for (const ch of raw) {
        if (isDigit(ch)) {
          if (!hasDecimal) integer += ch;
          else if (fraction.length < f.fractionDigits) fraction += ch;
        } else if (!hasDecimal && f.fractionDigits > 0 && isDecimalKey(ch, f)) {
          hasDecimal = true;
        }
      }
      integer = integer.replace(/^0+(?=\d)/, "");
      return hasDecimal ? `${integer || "0"}.${fraction}` : integer;
    }
    
    function toDisplay(canonical: string, f: MoneyFormat) {
      if (!canonical) return "";
      const [integer, fraction] = canonical.split(".");
      const grouped = new Intl.NumberFormat(f.locale, { numberingSystem: "latn", maximumFractionDigits: 0 }).format(
        BigInt(integer || "0"),
      );
      return fraction === undefined ? grouped : `${grouped}${f.decimal}${fraction}`;
    }
    
    const toNumber = (canonical: string) => (canonical === "" ? null : Number(canonical));
    const toCanonical = (value: number | null, f: MoneyFormat) => (value === null ? "" : value.toFixed(f.fractionDigits));
    
    /** Digits and the decimal separator are "significant"; group separators are not. */
    function countSignificant(text: string, end: number, f: MoneyFormat) {
      let count = 0;
      for (let i = 0; i < end; i++) if (isDigit(text[i]) || isDecimalKey(text[i], f)) count++;
      return count;
    }
    
    function caretAfter(display: string, significant: number, f: MoneyFormat) {
      if (significant <= 0) return 0;
      let count = 0;
      for (let i = 0; i < display.length; i++) {
        if (isDigit(display[i]) || display[i] === f.decimal) count++;
        if (count === significant) return i + 1;
      }
      return display.length;
    }
    
    /** Stable keys so only the characters that changed animate. */
    function toTokens(display: string, f: MoneyFormat) {
      const tokens: { key: string; ch: string }[] = [];
      let integerDigits = 0;
      let fractionDigits = 0;
      let inFraction = false;
      for (const ch of display) {
        if (isDigit(ch)) {
          const slot = inFraction ? `f${fractionDigits++}` : `d${integerDigits++}`;
          tokens.push({ key: `${slot}:${ch}`, ch });
        } else if (ch === f.decimal) {
          inFraction = true;
          tokens.push({ key: "decimal", ch });
        } else {
          tokens.push({ key: `g${integerDigits}`, ch });
        }
      }
      return tokens;
    }
    
    export function MoneyInput({
      value,
      defaultValue = null,
      onValueChange,
      currency = "USD",
      locale = "en-US",
      fractionDigits,
      max,
      size = "md",
      className,
      name,
      placeholder,
      ...props
    }: MoneyInputProps) {
      const format = useMemo(() => getFormat(locale, currency, fractionDigits), [locale, currency, fractionDigits]);
      const [text, setText] = useState(() => toCanonical(value ?? defaultValue, format));
      const [syncedValue, setSyncedValue] = useState(value);
      const [, forceRender] = useState(0);
      // Characters only roll in once the user types, so the initial value doesn't animate on load.
      const [hasTyped, setHasTyped] = useState(false);
      const inputRef = useRef<HTMLInputElement>(null);
      const overlayRef = useRef<HTMLSpanElement>(null);
      const pendingCaret = useRef<number | null>(null);
      const reduceMotion = useReducedMotion();
    
      // Follow a controlled value, unless it already matches what is typed (e.g. "12." vs 12).
      if (value !== undefined && value !== syncedValue) {
        setSyncedValue(value);
        if (toNumber(text) !== value) setText(toCanonical(value, format));
      }
    
      const display = toDisplay(text, format);
      const tokens = toTokens(display, format);
    
      useLayoutEffect(() => {
        const input = inputRef.current;
        if (!input) return;
        if (pendingCaret.current !== null && document.activeElement === input) {
          input.setSelectionRange(pendingCaret.current, pendingCaret.current);
        }
        pendingCaret.current = null;
        if (overlayRef.current) overlayRef.current.style.transform = `translateX(${-input.scrollLeft}px)`;
      });
    
      function handleChange(event: ChangeEvent<HTMLInputElement>) {
        const input = event.currentTarget;
        const caret = input.selectionStart ?? input.value.length;
        const next = sanitize(input.value, format);
        const amount = toNumber(next);
    
        if (max !== undefined && amount !== null && amount > max) {
          // React restores the previous text; put the caret back where it was.
          pendingCaret.current = Math.max(0, caret - (input.value.length - display.length));
          forceRender((n) => n + 1);
          return;
        }
    
        pendingCaret.current = caretAfter(toDisplay(next, format), countSignificant(input.value, caret, format), format);
        setHasTyped(true);
        setText(next);
        if (amount !== toNumber(text)) onValueChange?.(amount);
      }
    
      const symbol = (
        <span
          aria-hidden="true"
          className={cn("shrink-0 text-muted-foreground select-none", size === "lg" ? "text-2xl font-medium" : "text-sm")}
        >
          {format.symbol}
        </span>
      );
    
      const roll = reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 } }
        : {
            initial: { opacity: 0, y: "0.45em", filter: "blur(3px)" },
            animate: { opacity: 1, y: "0em", filter: "blur(0px)" },
          };
    
      return (
        <div
          onClick={() => inputRef.current?.focus()}
          className={cn(
            "flex w-full cursor-text items-center gap-1.5 rounded-md border border-input bg-background text-foreground shadow-xs",
            "transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
            "has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50",
            "has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20",
            size === "lg" ? "h-16 px-4 text-3xl font-semibold tracking-tight" : "h-10 px-3 text-sm",
            className,
          )}
        >
          {format.symbolFirst ? symbol : null}
          <span className="relative flex h-full min-w-0 flex-1 items-center overflow-hidden">
            <input
              ref={inputRef}
              type="text"
              inputMode={format.fractionDigits > 0 ? "decimal" : "numeric"}
              autoComplete="off"
              spellCheck={false}
              value={display}
              onChange={handleChange}
              onScroll={(event) => {
                if (overlayRef.current) overlayRef.current.style.transform = `translateX(${-event.currentTarget.scrollLeft}px)`;
              }}
              placeholder={placeholder ?? (format.fractionDigits ? `0${format.decimal}${"0".repeat(format.fractionDigits)}` : "0")}
              className="h-full w-full min-w-0 bg-transparent leading-none text-transparent tabular-nums caret-foreground outline-none [font-kerning:none] selection:bg-primary/20 placeholder:text-muted-foreground/60 disabled:cursor-not-allowed"
              {...props}
            />
            {/* The visible digits: an aria-hidden copy of the input's text, animated per character. */}
            <span
              ref={overlayRef}
              aria-hidden="true"
              className="pointer-events-none absolute inset-y-0 left-0 flex items-center leading-none whitespace-pre tabular-nums [font-kerning:none]"
            >
              {/* Plain keyed list, no AnimatePresence: removed characters vanish at once (exit
                  animations stranded stale separators during fast typing), new ones roll in, and
                  the rest glide to their new spots. */}
              {tokens.map((token) => (
                <motion.span
                  key={token.key}
                  layout={reduceMotion ? false : "position"}
                  variants={roll}
                  initial={hasTyped ? "initial" : false}
                  animate="animate"
                  transition={{ duration: duration.quick, ease: ease.out, layout: { duration: duration.quick, ease: ease.out } }}
                  className="inline-block"
                >
                  {token.ch}
                </motion.span>
              ))}
            </span>
          </span>
          {format.symbolFirst ? null : symbol}
          {name ? <input type="hidden" name={name} value={text} /> : null}
        </div>
      );
    }

Usage

import { MoneyInput } from "@/components/ui/money-input/money-input";

export function TransferAmount() {
  const [amount, setAmount] = useState<number | null>(null);
  return <MoneyInput aria-label="Amount" currency="IDR" locale="id-ID" value={amount} onValueChange={setAmount} />;
}

API reference

PropTypeDefaultDescription
valuenumber | null–Controlled amount. null means empty.
defaultValuenumber | nullnullInitial amount when uncontrolled.
onValueChange(value: number | null) => void–Called with the parsed amount, or null when the field is empty.
currencystring"USD"ISO 4217 currency code.
localestring"en-US"BCP 47 locale for the separators and the symbol position.
fractionDigitsnumberthe currency's minor units (0 for IDR)Digits allowed after the decimal separator.
maxnumber–Largest accepted amount; keystrokes that would exceed it are ignored.
size"md" | "lg""md"Field size. "lg" suits a hero amount.
classNamestring–Classes for the outer field.

Also accepts every native <input> attribute, including ref and className.

  • Separators, symbol and symbol position come from Intl.NumberFormat, so any locale and ISO 4217 currency works.
  • IDR defaults to whole rupiah. Pass fractionDigits to override any currency.
  • Pass name to submit the plain number (e.g. 1250.5) through a hidden input.

Accessibility

  • The field is a real text input with inputMode="decimal" (or "numeric" for whole-unit currencies), so mobile keyboards show digits.
  • Screen readers read the formatted value from the input itself; the animated digits are an aria-hidden copy.
  • The currency symbol is decorative. Give the field a label or aria-label that names the currency if it is not obvious.
  • The caret stays next to the digit you typed even when separators are inserted or removed.
  • With reduced motion enabled, digits fade in place instead of rolling.