Skip to content

CurrencyField

LG, under 3300B gzipped

A money input that parses the locale's own separators, so "1.234,56" means what a German user meant.

Category
Forms & input
Budget
lg, composes several components
Runtime deps
None

Preview

Preview language

This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.

Why this exists

CurrencyField

Deutsch

parseFloat on a German-formatted amount silently truncates at the thousands separator. Twelve hundred euros becomes one euro twenty-three.

What most apps ship

€1.23

parseFloat("1.234,56")

CurrencyField

1.234,56 €

currency-field

Install

npx shadcn@latest add https://kata-ui-rho.vercel.app/r/currency-field.json

Copies the source into your project. Pulls in 4 primitives: cn, format, use-locale, locale.

Source

"use client";

import { useId, useMemo, useState } from "react";
import { cn } from "../lib/cn";
import { numberFormat } from "../lib/format";
import { useLocale } from "../lib/use-locale";

export interface CurrencyFieldProps {
  label: string;
  name: string;
  currency: string;
  /** Amount in major units. `null` when the field is empty. */
  value: number | null;
  onChange: (value: number | null) => void;
  description?: string;
  error?: string;
  required?: boolean;
  className?: string;
}

/**
 * Parse a number the way the locale writes it.
 *
 * `parseFloat("1.234,56")` returns 1.234 — a German user typing twelve hundred
 * euros gets charged one. There is no `Intl` parser, so the separators are
 * recovered from `formatToParts` of a known number and used to normalise the
 * input before parsing.
 */
function parseLocaleNumber(input: string, locale: string): number | null {
  const trimmed = input.trim();
  if (!trimmed) return null;

  const parts = numberFormat(locale).formatToParts(12345.6);
  const group = parts.find((part) => part.type === "group")?.value ?? ",";
  const decimal = parts.find((part) => part.type === "decimal")?.value ?? ".";

  // Strip everything that is not a digit, a sign, or the locale's own decimal
  // mark — including the group separator, currency symbols, and the
  // non-breaking spaces several locales use as separators.
  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;
}

/**
 * A money input that reads and writes the locale's own number format.
 *
 * While focused it shows raw, editable text — reformatting mid-typing fights
 * the caret and is the single most hated behaviour in currency inputs. On blur
 * it formats through `Intl.NumberFormat` with the given currency, so the
 * committed value is unambiguous. The parse step understands the locale's
 * group and decimal separators, which is what makes "1.234,56" mean what a
 * German user meant by it.
 *
 * `inputMode="decimal"` gets the numeric keypad on phones without the
 * spinner and scroll-to-change hazards of `type="number"`.
 */
export function CurrencyField({
  label,
  name,
  currency,
  value,
  onChange,
  description,
  error,
  required,
  className,
}: CurrencyFieldProps) {
  const id = useId();
  const { locale, direction } = useLocale();
  const [draft, setDraft] = useState<string | null>(null);

  const formatted = useMemo(
    () => (value === null ? "" : numberFormat(locale, { style: "currency", currency }).format(value)),
    [value, locale, currency],
  );

  const descriptionId = `${id}-description`;
  const errorId = `${id}-error`;
  const describedBy = cn(description && descriptionId, error && errorId) || undefined;

  return (
    <div dir={direction} className={cn("flex flex-col gap-1.5 text-start", className)}>
      <label htmlFor={id} className="text-sm font-medium">
        {label}
        {required && (
          <span className="ms-1 text-red-600 dark:text-red-400" aria-hidden="true">
            *
          </span>
        )}
      </label>

      {description && (
        <p id={descriptionId} className="text-sm text-neutral-600 dark:text-neutral-400">
          {description}
        </p>
      )}

      <input
        id={id}
        name={name}
        inputMode="decimal"
        required={required}
        aria-required={required || undefined}
        aria-invalid={error ? true : undefined}
        aria-describedby={describedBy}
        value={draft ?? formatted}
        onFocus={() => setDraft(value === null ? "" : String(value))}
        onChange={(event) => setDraft(event.target.value)}
        onBlur={() => {
          onChange(draft === null ? value : parseLocaleNumber(draft, locale));
          setDraft(null);
        }}
        className={cn(
          "w-full rounded-md border bg-white px-3 py-2 text-start text-base tabular-nums dark:bg-neutral-950",
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
          error ? "border-red-500" : "border-neutral-300 dark:border-neutral-700",
        )}
      />

      {error && (
        <p id={errorId} className="text-sm text-red-700 dark:text-red-400">
          {error}
        </p>
      )}
    </div>
  );
}

What CI checks

← All components