Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Eye, EyeOff, Info } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- */
/* Schemes                                                                     */
/* -------------------------------------------------------------------------- */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/bank-details-form.json

Prompt

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-pressed toggle. 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 summarysummarizeBankDetails returns 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.

On This Page