Bank Details Form
Payout details whose fields follow the country — IBAN and BIC for SEPA, sort code and account number for the UK, routing and account number for the US — with a real mod-97 IBAN check, the ABA weighted checksum, and the account masked after entry.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/bank-details-form.jsonPrompt
Build a React + TypeScript + Tailwind "BankDetailsForm" component
(lucide-react icons, shadcn Input / Label / Button / Badge, a cn() class
merger). No form element and no submit: it is a controlled field set that
the host drops into its own form.
Contract
- Props: country (ISO 3166-1 alpha-2, case-insensitive), value, onChange,
accountHolderRequired (default true), disabled, className, and the rest
spread on the root. forwardRef to the root div, role="group" named by its
own heading.
- value is flat and compact — no separators, IBAN and BIC uppercase:
{ accountHolder, iban, bic, sortCode, routingNumber, accountNumber }.
onChange(next) fires on every accepted edit, and once on its own after a
country change that re-scoped the value.
- Two lookup tables carry the domain, both exported so a fork can extend
them without touching logic:
BANK_SCHEMES: sepa | uk | us -> { network, fields, nameLimit, note,
identifierLabel, destinationLabel }.
BANK_COUNTRIES: { code, name, scheme, ibanLength?, bicRequired? } — the
SEPA countries with the IBAN length ISO 13616 registers for each, GB
overridden to the uk scheme (GB IBANs exist, GB payouts do not use them),
and US.
- Export the pure helpers too: validateBankDetails(value, country, opts),
summarizeBankDetails(value, country, opts), changeBankCountry(value,
country), getBankCountryRule, isIbanChecksumValid, isRoutingChecksumValid,
formatIban, formatSortCode, EMPTY_BANK_DETAILS — so a parent can gate its
own submit button without mounting anything.
Behavior
- The field set follows the country: SEPA -> IBAN + BIC; UK -> sort code +
account number; US -> routing number + account number. Account holder is
always first.
- IBAN: ISO 7064 MOD-97-10 exactly as ISO 13616 defines it — move the first
four characters to the end, map A-Z to 10-35, and the resulting integer
must leave remainder 1 modulo 97. Fold the remainder one digit at a time;
that number runs far past Number.MAX_SAFE_INTEGER. Check the registered
length for the IBAN's own country first, and refuse an IBAN whose country
is not the selected one — that is an account in the wrong country, not a
typo, so the message must say which country it belongs to. Displayed in
groups of four.
- Routing number: nine digits, then 3(d1+d4+d7) + 7(d2+d5+d8) + (d3+d6+d9)
must be 0 mod 10. Check the leading routing symbol as well (01-12, 21-32,
61-72, 80): 000000000 passes the weighted sum and still has to be refused.
- Sort code: six digits shown as 12-34-56. No arithmetic check exists for
it, so do not pretend otherwise. UK account numbers are 8 digits, US ones
4 to 17.
- BIC: 4 letters, a 2-letter country, 2 alphanumerics, an optional
3-character branch. Optional inside the EEA (IBAN-only since EU Reg.
260/2012), required for the SEPA countries outside it. Its country
subfield is checked against the selected country the same way the IBAN's
is — a BIC naming a bank in another country is what makes an otherwise
valid IBAN unroutable.
- The beneficiary name is capped per scheme because the schemes' own records
are: 70 characters for SEPA, 18 for a Bacs record, 22 for a NACHA one.
Show a counter for the last 15 characters.
- A country change re-scopes the value in a render-phase state adjustment,
not an effect — an effect paints one frame of the old country's values
under the new country's labels. The account holder always travels,
shortened to the new budget; the account number is the one field the UK
and the US share, so it travels when it also fits the destination's length
rule; an IBAN can never follow you to another country, and a BIC travels
only to the country in its own 5th and 6th characters — judge it on those
and not on whether an IBAN was cleared beside it, or a German BIC held
without an IBAN survives a switch to France. Everything cleared is named
with its reason in a panel and in a permanently mounted polite live
region. A country with no rule leaves the value untouched.
- Errors surface on blur, or as soon as a value is complete for its scheme —
a checksum failure should not wait for a blur that may never come — and
clear again as you type. A field showing an error never masks.
- Masking: account number and IBAN mask on blur to their last four (the IBAN
keeps its country and check digits visible), with a reveal toggle carrying
aria-pressed. Masking is length-preserving, so the caret keeps its meaning
under a click. Routing numbers, sort codes and BICs are never masked: they
identify a bank, not an account.
- Every field keeps the caret across the edit it re-writes, re-applied in a
layout effect: formatted ones by counting characters in the field's own
alphabet rather than display offsets, the name by re-measuring its prefix
through the same cleaner — a keystroke mid-string in a name already at the
cap must not throw the caret to the end. Backspace and Delete reach across
a separator instead of eating it.
- The value never leaves the component except through onChange. Nothing is
logged, and the inputs carry no name attribute, so a stray native submit
in a surrounding form cannot post an account number.
- Unsupported country: an explanation naming the code and the way out, never
a set of dead disabled inputs.
Rendering & styling
- Semantic tokens only: bg-card / bg-muted panels, text-muted-foreground
hints, text-destructive errors inside role="alert", text-primary for the
checksum tick, border-border, and the focus-visible rings the shadcn
primitives already carry. cn() merges className.
- One @container: fields stack in a single column and split into two above
26rem, with account holder, IBAN and BIC spanning both. The message slot
under every field holds its height, so an error cannot shove the layout.
- No animation, so there is nothing to gate on prefers-reduced-motion; the
only transitions are the inputs' own colour changes.
Customization levers
- Countries and schemes: one row in BANK_COUNTRIES adds a SEPA country; a
BANK_SCHEMES entry (fields, name budget, labels, note) adds a rail this
does not cover — CLABE for MX, BSB for AU, IFSC for IN.
- Scheme note: the sentence under the summary is data, not law. Replace it
with your own reviewed copy, or drop it and let the surrounding page make
the claims.
- BIC policy: bicRequired is per country. Make it required everywhere if
your processor asks for it, or drop the field for EEA-only payouts.
- Strictness: the country-must-match rules for the IBAN and the BIC, and the
routing-symbol range, are single branches in validateBankDetails — loosen
any of them if your processor is more forgiving.
- Disclosure: change the masked tail from four characters, mask the sort
code as well, or drop the reveal button and rely on focus alone.
- Density: move the 26rem split, put the summary in a sibling column, or
render the summary on its own as a read-only view of stored details.Concepts
- Country-driven field set — the country is not a label on the form, it is what decides which fields exist at all. IBAN and BIC, sort code and account number, routing and account number are three different questions; asking the wrong one collects a number no bank can route.
- Real check digits — mod-97 and the ABA weighted sum are the actual algorithms, folded digit by digit, not a length regex wearing their name. They catch the single mistyped digit a shape check waves through, which is the mistake that sends money to a stranger.
- Carry-over on switch — the account number is the one field the UK and the US share, so it survives a switch when it also fits the destination's length rule; an IBAN encodes its own country and can never follow you. Whatever cannot travel is cleared and named, because silently emptying a field somebody already filled is worse than asking again.
- Mask after entry, reveal on demand — the account masks on blur to its last four and unmasks on focus or on an
aria-pressedtoggle. The mask is length-preserving, so a click still lands the caret where it looked like it would, and a field holding an error stays legible: you cannot fix what you cannot read. - Log-safe summary —
summarizeBankDetailsreturns the beneficiary, the public bank identifier and the account already masked. It is the shape to render on a confirmation screen and the shape to write to a log; nothing here hands the account back whole. - Blocked, not dead — a country with no rule gets the reason and the fix, and leaves the entered value alone. A disabled input with no explanation tells nobody what would make it work.
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.
Username Availability
A controlled handle field that gates a debounced availability check behind local rules, names the rule that broke, tells unreachable apart from taken, and shows the normalised handle before submit.