MoneyInput
Currency field that groups thousands live while typing and formats to fixed decimals on blur.
import { MoneyInput }from "@/components/ui"
Live thousand separators
KES
Full source
The complete component, read from src/components/ui/money-input.tsx.
money-input.tsx
"use client";
import { useState, useRef } from "react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";
function sanitizeMoney(raw: string, allowNegative: boolean, decimals: number): string {
let s = raw.replace(allowNegative ? /[^0-9.-]/g : /[^0-9.]/g, "");
if (allowNegative) {
const negative = s.startsWith("-");
s = s.replace(/-/g, "");
if (negative) s = "-" + s;
}
const dot = s.indexOf(".");
if (dot !== -1) s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, "");
if (decimals === 0) s = s.split(".")[0];
else if (dot !== -1) {
const [i, f = ""] = s.split(".");
s = i + "." + f.slice(0, decimals);
}
return s;
}
function moneyToNumber(s: string): number | null {
if (s === "" || s === "-" || s === ".") return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
function groupMoney(s: string, locale: string): string {
if (s === "" || s === "-") return s;
const negative = s.startsWith("-");
const unsigned = negative ? s.slice(1) : s;
const dot = unsigned.indexOf(".");
const intPart = dot === -1 ? unsigned : unsigned.slice(0, dot);
const hasDot = dot !== -1;
const fracPart = hasDot ? unsigned.slice(dot + 1) : "";
const groupedInt =
intPart === ""
? ""
: Number(intPart).toLocaleString(locale, { useGrouping: true, maximumFractionDigits: 0 });
let out = groupedInt;
if (hasDot) out += "." + fracPart;
if (negative) out = "-" + out;
return out;
}
function digitsBefore(s: string, pos: number): number {
let n = 0;
for (let i = 0; i < pos && i < s.length; i++) {
if (s[i] >= "0" && s[i] <= "9") n++;
}
return n;
}
function indexAfterDigits(s: string, count: number): number {
if (count <= 0) {
return s.startsWith("-") ? 1 : 0;
}
let seen = 0;
for (let i = 0; i < s.length; i++) {
if (s[i] >= "0" && s[i] <= "9") {
seen++;
if (seen === count) return i + 1;
}
}
return s.length;
}
interface MoneyInputProps {
id?: string;
label?: string;
hint?: string;
error?: string;
disabled?: boolean;
readOnly?: boolean;
placeholder?: string;
currency?: string;
value: number | null;
onChange: (value: number | null) => void;
decimals?: number;
allowNegative?: boolean;
max?: number;
min?: number;
locale?: string;
}
export function MoneyInput({
id,
label,
hint,
error,
disabled,
readOnly,
placeholder = "0.00",
currency,
value,
onChange,
decimals = 2,
allowNegative = false,
max,
min,
locale = "en-KE",
}: MoneyInputProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const format = (n: number | null) =>
n === null || Number.isNaN(n)
? ""
: n.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
const display = editing ? groupMoney(draft, locale) : format(value);
return (
<Field label={label} hint={hint} error={error} htmlFor={id}>
<div className="relative flex items-center">
{currency && (
<span className="pointer-events-none absolute left-3 text-sm text-text-tertiary">
{currency}
</span>
)}
<input
ref={inputRef}
id={id}
inputMode="decimal"
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder}
value={display}
aria-invalid={Boolean(error) || undefined}
onFocus={() => {
setEditing(true);
setDraft(value === null ? "" : String(value));
}}
onChange={(e) => {
const el = e.target;
const prevGrouped = el.value;
const caret = el.selectionStart ?? prevGrouped.length;
const digitsLeft = digitsBefore(prevGrouped, caret);
const cleaned = sanitizeMoney(prevGrouped, allowNegative, decimals);
setDraft(cleaned);
onChange(moneyToNumber(cleaned));
const grouped = groupMoney(cleaned, locale);
const nextCaret = indexAfterDigits(grouped, digitsLeft);
requestAnimationFrame(() => {
const node = inputRef.current;
if (node) node.setSelectionRange(nextCaret, nextCaret);
});
}}
onBlur={() => {
setEditing(false);
let n = moneyToNumber(draft);
if (n !== null) {
if (typeof max === "number") n = Math.min(n, max);
if (typeof min === "number") n = Math.max(n, min);
}
onChange(n);
}}
className={cn(
controlBase,
controlClasses(error, disabled, readOnly),
"h-10 tabular-nums text-right pr-3",
currency ? "pl-12" : "pl-3"
)}
/>
</div>
</Field>
);
}Dependencies
To use MoneyInput you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ field.tsx └─ money-input.tsx src/lib/ └─ utils.ts