MoneyInput
Currency field that groups thousands live while typing and formats to fixed decimals on blur.
import { MoneyInput }from "@/components/ui"
Specification
Collects a monetary amount, grouping thousands live as the user types and formatting to fixed decimals on blur.
Props (15)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Explicit element id, wired to the label. |
| label | string | — | Visible field label shown above the input. |
| hint | string | — | Helper text shown below the field when there is no error. |
| error | string | — | Error message; renders the field in the error state. |
| disabled | boolean | — | Disables the input. |
| readOnly | boolean | — | Makes the field read-only. |
| placeholder | string | "0.00" | Placeholder shown when the value is empty. |
| currency | string | — | Currency code shown as a static prefix inside the field (e.g. "KES"). |
| value* | number | null | — | Controlled numeric value; null when empty. |
| onChange* | (value: number | null) => void | — | Called with the parsed number (or null) as the user types and on blur. |
| decimals | number | 2 | Number of decimal places to keep and format to. |
| allowNegative | boolean | false | Permits a leading minus sign for negative amounts. |
| max | number | — | Upper bound clamped on blur. |
| min | number | — | Lower bound clamped on blur. |
| locale | string | "en-KE" | Locale used for thousands grouping and decimal formatting. |
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
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/components/ui/field.tsx
field.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldCounter {
text: string;
warn?: boolean;
}
interface FieldProps {
label?: string;
hint?: string;
error?: string;
required?: boolean;
htmlFor?: string;
counter?: FieldCounter;
messageId?: string;
children: ReactNode;
}
export function Field({
label,
hint,
error,
required,
htmlFor,
counter,
messageId,
children,
}: FieldProps) {
const message = error || hint;
return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label
htmlFor={htmlFor}
className="flex items-center gap-1 text-[13px] font-medium text-text"
>
{label}
{required && (
<span className="text-error" aria-hidden>
*
</span>
)}
</label>
)}
{children}
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
>
{message || "\u00A0"}
</p>
{counter && (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
counter.warn ? "text-error" : "text-text-tertiary"
)}
>
{counter.text}
</span>
)}
</div>
</div>
);
}
export const controlBase = cn(
"w-full rounded-lg border text-sm bg-surface text-text transition-colors",
"placeholder:text-text-tertiary",
"focus:outline-none focus-visible:outline-none focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
"read-only:bg-bg-secondary"
);
export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
return cn(
error ? "border-error focus-visible:border-error" : "border-border",
readOnly && "read-only:bg-bg-secondary",
disabled && "text-text-tertiary"
);
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}