Inputs

Tax ID Input

A tax identifier field that knows what it is asking for — the country picks the local name, prefix and mask, and validation runs that jurisdiction's real checksum instead of a generic regex.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleAlert, CircleCheck, Info, LoaderCircle, ScanLine, ShieldCheck, TriangleAlert } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"

export type TaxIdCountry = "AU" | "BR" | "DE" | "FR" | "GB" | "IN" | "IT" | "US"

/* ------------------------------------------------------------------ *
 * Structural checks. Each one is the published algorithm for that
 * jurisdiction, not a regex that happens to be the right length.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tax-id-input.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "TaxIdInput" component (lucide-react
icons, shadcn Button / Badge / Label; no other runtime deps).

Contract
- forwardRef onto the inner <input>; props extend InputHTMLAttributes minus
  value / onChange / type / inputMode.
- country: one of "AU" | "BR" | "DE" | "FR" | "GB" | "IN" | "IT" | "US".
- value: string — the bare identifier, uppercase, no separators, no country
  prefix. A value that still carries them ("FR 40 303 265 045" pasted off an
  invoice) is normalised on the way in.
- onChange(value, result) where result is
  { state: "empty" | "incomplete" | "invalid" | "valid"; raw; formatted;
    canonical; reason: string | null }. canonical is what you would send to a
  register (prefix included where a country prints one). Export the same
  checkTaxId(country, input) function the field uses, so a form validates on
  submit with the identical implementation.
- verification?: { status: "checking" | "verified" | "rejected" |
  "unavailable"; canonical?; name?; checkedAt?; message? } — the consumer owns
  the lookup; the field only renders what came back.
- onVerify?(canonical, country): supply it to render the "Check <register>"
  button.
- A per-country table drives everything: local name (USt-IdNr., Numéro de TVA,
  Partita IVA, VAT registration number, EIN, ABN, GSTIN, CNPJ), English gloss,
  printed prefix, register name, mask, accepted lengths, alphabet, example,
  and what passing actually proves.

Behavior
- Real algorithms, each commented with its rule and its known-good vectors —
  never a length regex wearing a checksum's name:
  · DE  ISO 7064 MOD 11,10 over the first 8 digits.
  · FR  SIREN Luhn plus the numeric key (12 + 3 x (SIREN mod 97)) mod 97;
        legacy letter keys are structurally accepted, not asserted.
  · IT  11-digit Luhn, matricola not all zeros.
  · GB  mod 97 on weights 8,7,6,5,4,3,2 plus the two-digit check block;
        accept the +55 (9755 series) variant too; 9 or 12 digits.
  · US  EIN has NO check digit — test length plus the IRS campus prefix
        (17 prefixes have never been issued) and say so in the copy.
  · AU  ABN: subtract 1 from the first digit, weights
        10,1,3,5,7,9,11,13,15,17,19, sum mod 89 = 0.
  · IN  GSTIN: state code 01-38 / 97 / 99, embedded PAN, 14th char Z, base-36
        check character with alternating 1,2 weights.
  · BR  CNPJ mod 11 twice; a character is worth charCode - 48, which covers the
        alphanumeric CNPJ and collapses to the classic rule for numeric ones.
- Live masking with caret survival: track the caret as "how many identifier
  characters precede it", re-mask, then restore. Backspace over a separator
  hops onto the character behind it instead of dying. Illegal characters are
  dropped in place, so paste works. Input is capped at the longest accepted
  length, but the printed prefix is peeled before the cap applies, so typing
  "FR40303265045" by hand ends where pasting it does.
- Four field states, each with its own line under the field: empty (format +
  worked example), incomplete ("9 of 11 digits entered"), invalid (the reason
  names the failing digit — "The key for SIREN 303265045 is 40, not 41"), and
  valid, which says what passed AND that nothing has been checked against the
  register yet.
- Verification precedence: a register match outranks the local sum (the
  register is the authority), then structural failures, then checking /
  rejected / unavailable. "Unavailable" must not borrow the language of a
  rejection — the number is unconfirmed, not wrong.
- Staleness: when verification.canonical is supplied and no longer matches the
  field, drop the answer entirely rather than leaving a green tick over digits
  nobody checked; an empty field drops the answer either way, since there is no
  number left for it to be about.
- Blocked lookup explains itself: the button is disabled while the value is
  empty / incomplete / invalid / in flight / the field is locked, and a line
  beside it says which of those it is, wired through aria-describedby.
- Claim nothing the data does not say: render name and checkedAt only when the
  consumer supplies them, and never state a tax consequence.

Rendering & styling
- Semantic tokens only: border-input shell, focus-within:border-ring +
  ring-ring/50, border-destructive + text-destructive when invalid,
  border-primary/50 and a primary check icon when valid, bg-muted for the
  country-prefix chip, text-muted-foreground for supporting copy.
- Label = the local name; the English gloss and (when verified) a Badge sit on
  the right of the label row. Input is tabular-nums, inputMode numeric for
  digit-only formats, autoComplete off, spellCheck off.
- Status line is role=status aria-live=polite and is referenced by
  aria-describedby together with the blocker line; aria-invalid follows the
  destructive state.
- The only animation is the lookup spinner, gated with
  motion-reduce:animate-none; the field works with animation off. cn() merges
  the consumer className onto the wrapper.

Customization levers
- Country set: the format table is the whole extension point — add BE
  (97 - (first8 mod 97)), PL (weights 6,5,7,2,3,4,5,6,7 mod 11) or ES by
  appending one entry; nothing else in the component knows a country name.
- Mask: "#" is a slot, everything else is a literal, so regroup ABN as
  "### ### ### ##" or drop the separators entirely without touching validation.
- Copy tone: localName / englishName / passNote / register are all strings on
  the format entry — translate them, or drop englishName for a denser row.
- Verification surface: swap the inline button for a form-level "Verify all"
  action by leaving onVerify out and rendering the verification prop alone.
- Strictness: to let people save a number the checksum rejects, treat invalid
  as a warning tone in your form layer — the component reports, it never
  blocks typing.
- Density: h-9 / text-sm / mt-1.5 are the sizing knobs; move the status line
  into a tooltip if the form is very tight, keeping aria-describedby intact.

Concepts

  • The country is the format — one prop swaps the label to the local name of the number, the printed prefix, the mask, the placeholder, the accepted lengths and the algorithm. Nothing else in the component knows a country name, so adding a jurisdiction is one table entry.
  • Structural check, not a regex — each country runs its published rule (ISO 7064 MOD 11,10, mod 97, modulus 89, the GSTIN base-36 character, CNPJ mod 11), so a transposed pair of digits is caught at the keystroke rather than by a payment processor three days later.
  • Reasons, not a red border — an invalid number says which part failed ("the key for SIREN 303265045 is 40, not 41"), because "invalid tax ID" tells a user nothing they can act on.
  • Structurally valid is not registered — passing the checksum only proves the digits are internally consistent; the field says so out loud and keeps the register lookup as a separate, visibly distinct state.
  • A register answer outranks local arithmetic — the register is the authority and the checksum is a pre-filter that saves it a round trip, so a confirmed match wins the display.
  • Stale answers retire themselves — tag a verification with the canonical it belongs to and editing one digit drops it, which is the difference between a verified badge and a decorative one; clearing the field drops it whether or not it was tagged, because an empty input has no number to vouch for.
  • A blocked action explains itself — the lookup button never renders as a dead disabled control: it says whether it is waiting on more digits, on a fix, on the register, or on an unlocked field.

On This Page