CurrencyInput
MD · 1950B gzip budgetNumeric input with currency symbol and locale formatting.
- Category
- Forms & input
- Budget
- md, several states or a live subscription
- External runtime
- React / React DOM only
axe fixtureSSR rendersource scanbundle budget
Preview
Live fixtureCurrency: USD
Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.
Install
npx shadcn@latest add https://gear5-ui.vercel.app/r/currency-input.jsonCopies the source into your project. Pulls in 3 shared registry items: cn, use-locale, locale.
New here? Set up Tailwind and import aliases first →Source & props
Edit on GitHub ↗Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.
"use client";
import { useId, useMemo, useState } from "react";
import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";
export interface CurrencyInputProps {
value: number | null;
onChange: (value: number | null) => void;
label: string;
currency?: string;
locale?: string;
className?: string;
}
function parseLocaleNumber(input: string, locale: string): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
const parts = new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).formatToParts(12345.6);
const group = parts.find((p) => p.type === "group")?.value ?? ",";
const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
const normalised = trimmed
.split(group).join("")
.replace(/\s/g, "")
.replace(decimal, ".")
.replace(/[^0-9.\-]/g, "");
const parsed = Number.parseFloat(normalised);
return Number.isFinite(parsed) ? parsed : null;
}
export function CurrencyInput({ value, onChange, label, currency = "USD", locale: localeProp, className }: CurrencyInputProps) {
const id = useId();
const { locale: ctxLocale } = useLocale();
const locale = localeProp || ctxLocale;
const [draft, setDraft] = useState<string | null>(null);
const formatted = useMemo(() => {
if (value === null) return "";
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
}, [value, locale, currency]);
const symbol = useMemo(() => {
const parts = new Intl.NumberFormat(locale, { style: "currency", currency }).formatToParts(0);
return parts.find((p) => p.type === "currency")?.value ?? currency;
}, [locale, currency]);
return (
<div className={cn("flex flex-col gap-1.5 text-start", className)}>
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<div className="relative">
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-sm text-neutral-500 dark:text-neutral-400" aria-hidden="true">
{symbol}
</span>
<input
id={id}
type="text"
inputMode="decimal"
value={draft ?? formatted}
onFocus={() => setDraft(value === null ? "" : String(value))}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
onChange(draft === null ? value : parseLocaleNumber(draft, locale));
setDraft(null);
}}
aria-label={`${label} in ${currency}`}
className="w-full rounded-md border border-neutral-300 bg-white ps-8 pe-3 py-2 text-base tabular-nums focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-neutral-700 dark:bg-neutral-950"
/>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Currency: {currency}
</p>
</div>
);
}
What CI checks
Quality contract- Bundled, minified and gzipped against its tier budget
- Audited by axe in the state previewed above
- Rendered through react-dom/server with no browser globals
- Scanned for network calls, dangerous sinks, and unguarded animation