WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Component guides
  4. IBAN Validation JavaScript: Mod 97, Regex and a React Field
  1. Blog
  2. Component guides
  3. IBAN Validation JavaScript: Mod 97, Regex and a React Field
Component guides
Component guides

IBAN Validation JavaScript: Mod 97, Regex and a React Field

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 12 min read

On this page

0%
  1. Why is a regex not enough to validate an IBAN?
  2. How does the IBAN mod 97 check work?
  3. How do I validate an IBAN in TypeScript?
  4. What is the Romanian IBAN format?
  5. How do I build an IBAN field that formats as you type?
  6. How do I show an IBAN so people can copy it?
  7. Does a valid IBAN mean the account exists?
  8. Should I use a library like ibantools instead?
  9. Should I copy this code or install it?
  10. FAQ
    1. How do I validate an IBAN in JavaScript?
    2. What is the regex for an IBAN?
    3. How many characters is a Romanian IBAN?
    4. Does a valid IBAN mean the bank account exists?
    5. Why does my IBAN check reject valid IBANs?
    6. How do I find the bank from a Romanian IBAN?

TL;DR

To validate an IBAN in JavaScript, remove the spaces, uppercase it, check the length for its country, move the first four characters to the end, turn each letter into two digits (A is 10, Z is 35) and check that the whole number mod 97 equals 1. Carry the remainder one character at a time, because the number is longer than a JavaScript Number can hold exactly. A passing IBAN is well formed, which does not prove that the account exists.

Published Oct 9, 2026

Your payout settings, supplier form or invoice template asks for a bank account, and people paste IBANs in every shape: lowercase, grouped by four, with dashes, with the non-breaking spaces a PDF adds, sometimes with "IBAN:" in front. Many IBAN validation JavaScript snippets stop at a regex, which accepts a mistyped digit, or call parseInt, which rejects valid accounts. This tutorial writes the mod 97 check in TypeScript, wires it into a zod rule and a React field that formats as you type, reads the bank out of Romanian IBANs and displays the result with a copy button. The UI uses Masked Input (Pro), Input and Copy Button (both free) from Wingo UI, the library we build, so we are not neutral. The validator is plain TypeScript you can paste anywhere.

Why is a regex not enough to validate an IBAN?

A regex checks the shape, and a typo keeps the shape. The usual IBAN validation regex, run after you remove the spaces and uppercase, is this:

ts
const IBAN_SHAPE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/;

Two letters for the country, two check digits, then 11 to 30 letters or digits, because IBANs run from 15 characters (Norway) to a maximum of 34. A country pattern is stricter: Romania's is ^RO\d{2}[A-Z]{4}[A-Z0-9]{16}$. Both accept RO49AAAA1B31007593840001, which is the sample Romanian IBAN from the IBAN registry with its last digit changed.

The two check digits exist to catch exactly that. Any string in the Romanian shape passes the regex, while only about 1 in 97 random ones passes mod 97: in a run of 200,000 random strings, about 1% did. Keep the regex anyway, as the first of three checks, so your message can say "this is not an IBAN" before it says "this IBAN has a typo".

How does the IBAN mod 97 check work?

Move the first four characters to the end, replace each letter with two digits (A is 10, B is 11, up to Z at 35), read the result as one integer and divide it by 97. A valid IBAN leaves a remainder of 1. The IBAN article on Wikipedia (opens in a new tab) describes these steps from ISO 13616. Whoever issues the IBAN computes the check digits as 98 minus the remainder of the same number with 00 in their place, which is why a valid IBAN lands on 1.

Here is the UK sample IBAN worked by hand:

StepValue
Remove the spacesGB29NWBK60161331926819
Move the first four characters to the endNWBK60161331926819GB29
Letters to numbers (N 23, W 32, B 11, K 20, G 16)2332112060161331926819161129
Remainder mod 971

That integer has 28 digits, and a JavaScript Number is exact only up to 2^53, which has 16 digits. Number(digits) % 97 and parseInt(digits, 10) % 97 both return 5 for this IBAN, so they reject a valid account. BigInt(digits) % 97n works. Carrying the remainder one character at a time works too, and it skips the conversion to a digit string.

Wikipedia says the check detects all single substitution errors and all or nearly all swaps of neighboring characters. We tested that. On 2,000 random German IBANs, which are all digits after the country code, every single-digit change and every swap of two neighbors failed the check. Letters weaken it: on 2,000 random Romanian-style IBANs with letters in the account part, about 0.35% of single-character changes and of neighbor swaps still passed, roughly 1 in 300, because a letter becomes two digits. Swap the 1B in the registry sample to B1 and both read 111, so RO49AAAAB131007593840000 passes.

How do I validate an IBAN in TypeScript?

To validate IBAN numbers, normalize the input, then run three checks in order: the shape, the length for the country, and mod 97. Returning which check failed lets the form explain the problem instead of saying "invalid":

ts
// lib/iban.ts
// the 42 IBAN country codes in the SEPA schemes (EPC, August 2026), with their
// lengths from SWIFT's IBAN Registry as ibantools 4.5.4 ships them
const LENGTHS: Record<string, number> = Object.fromEntries(
(
"AD24 AL28 AT20 BE16 BG22 CH21 CY28 CZ24 DE22 DK18 EE20 ES24 FI18 FR27 " +
"GB22 GI23 GR27 HR21 HU28 IE22 IS26 IT27 LI21 LT20 LU20 LV21 MC27 MD24 " +
"ME22 MK19 MT31 NL18 NO15 PL28 PT25 RO24 RS22 SE24 SI19 SK24 SM27 VA22"
)
.split(" ")
.map((entry) => [entry.slice(0, 2), Number(entry.slice(2))]),
);
export type IbanProblem = "format" | "country" | "length" | "checksum";
export const ibanLength = (country: string) => LENGTHS[country.toUpperCase()];
// " iban: ro49 aaaa-1b31 ..." -> "RO49AAAA1B31..."; \s also covers the non-breaking spaces PDFs paste
export function electronicIban(input: string): string {
return input.trim().toUpperCase().replace(/^IBAN:?/, "").replace(/[\s.-]/g, "");
}
// the remainder of the rearranged IBAN, one character at a time, so the number never outgrows a float
function mod97(iban: string): number {
const rearranged = iban.slice(4) + iban.slice(0, 4);
let remainder = 0;
for (const char of rearranged) {
// A is 10 ... Z is 35: a letter adds two digits, so shift by 100
const value = char >= "A" ? char.charCodeAt(0) - 55 : Number(char);
remainder = (remainder * (value > 9 ? 100 : 10) + value) % 97;
}
return remainder;
}
export function checkIban(input: string): { iban: string; problem: IbanProblem | null } {
const iban = electronicIban(input);
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) return { iban, problem: "format" };
const length = ibanLength(iban.slice(0, 2));
if (!length) return { iban, problem: "country" };
if (iban.length !== length) return { iban, problem: "length" };
return { iban, problem: mod97(iban) === 1 ? null : "checksum" };
}
export const isValidIban = (input: string) => checkIban(input).problem === null;
// "RO49AAAA1B31007593840000" -> "RO49 AAAA 1B31 0075 9384 0000"
export const formatIban = (input: string) => electronicIban(input).replace(/(.{4})(?=.)/g, "$1 ");
// check digits for test data: ibanCheckDigits("GB", "NWBK60161331926819") -> "29"
export function ibanCheckDigits(country: string, bban: string): string {
return String(98 - mod97(`${country}00${bban}`)).padStart(2, "0");
}
ts
checkIban("gb29-nwbk-6016-1331-9268-19"); // { iban: "GB29NWBK60161331926819", problem: null }
checkIban("IBAN: RO49 AAAA 1B31 0075 9384 0001"); // problem: "checksum"
checkIban("US12345678901234567"); // problem: "country"
formatIban("ro49aaaa1b31007593840000"); // "RO49 AAAA 1B31 0075 9384 0000"
ibanCheckDigits("RO", "BTRL0123456789ABCDEF"); // "17", so RO17BTRL0123456789ABCDEF passes

Four decisions in that file are worth copying even if you rewrite the rest:

  • Uppercase first. "n".charCodeAt(0) - 55 is 55, where "N" gives 23, so a lowercase IBAN fails without it.
  • Strip every kind of space. \s in a JavaScript regex also matches the non-breaking and narrow spaces that PDFs and bank apps put between the groups.
  • Reject countries you do not pay. The table holds the 42 IBAN country codes in the EPC's August 2026 overview of SEPA scheme participants (opens in a new tab), Albania, Moldova, Montenegro, North Macedonia and Serbia included, so anything else gets "country", the honest answer for a SEPA payout form. If you accept IBANs from every country, use a library (see the last section).
  • Generate test data. ibanCheckDigits builds a valid IBAN from any country and account part, so your fixtures never hold a real person's account.

What is the Romanian IBAN format?

A Romanian IBAN has 24 characters: RO, two check digits, four letters for the bank, and 16 letters or digits for the branch and the account. The four bank letters are the first four characters of the bank's BIC, its SWIFT code, so BTRL is Banca Transilvania (BIC BTRLRO22). Wikipedia's IBAN formats by country (opens in a new tab) table writes it as RO kk bbbb cccc cccc cccc cccc.

PositionsRegistry sampleMeaning
1 to 2ROCountry
3 to 449Check digits
5 to 8AAAABank, the first four characters of its BIC
9 to 241B31 0075 9384 0000Branch and account, letters allowed

Two rules follow. A "digits only after the bank code" rule rejects real Romanian accounts, since the account part may hold letters: Banca Transilvania IBANs, for one, carry letters such as RONCRT right after the bank code. And the bank is one lookup away:

ts
// lib/iban.ts (continued)
// the four letters after the check digits are the start of the bank's BIC
const RO_BANKS: Record<string, string> = {
BTRL: "Banca Transilvania",
RNCB: "BCR",
BRDE: "BRD",
INGB: "ING Bank",
RZBR: "Raiffeisen Bank",
BACX: "UniCredit Bank",
CECE: "CEC Bank",
TREZ: "State Treasury",
};
export function romanianBank(input: string): string | null {
const iban = electronicIban(input);
return iban.startsWith("RO") ? (RO_BANKS[iban.slice(4, 8)] ?? null) : null;
}
ts
romanianBank("RO17 BTRL 0123 4567 89AB CDEF"); // "Banca Transilvania"
romanianBank("GB29 NWBK 6016 1331 9268 19"); // null

Show the name as a hint the person can recognize. It proves nothing about the account, and the map is a snapshot of banks that merge: when OTP Bank Romania merged into Banca Transilvania in early 2025, its customers received new BTRL IBANs, and Banca Transilvania sent employers the new IBANs for salaries paid from March 2025 (opens in a new tab). An old OTP IBAN still passes mod 97.

How do I build an IBAN field that formats as you type?

Use a masked field that keeps the raw value apart from what it displays. The iban preset of Masked Input uppercases as you type, groups by four, keeps the caret right after the character you typed and cleans separators out of a paste. Once the IBAN is complete it runs mod 97 and pops in a check or an alert icon; the red ring and aria-invalid wait until you leave the field. On phones it keeps the text keyboard with autoCapitalize="characters", because a numeric keypad has no letters for the country code.

Type or paste gb29-nwbk-6016-1331-9268-19: the field uppercases it, groups it by four and shows a check. Change the last digit and the check turns into an alert
$ npx wingo-ui@latest add masked-input
ProMasked Input docs

The preset ships lengths for nine countries: Romania, Moldova, Germany, the UK, France, Italy, Spain, Bulgaria and Hungary. For any other country it still groups by four but waits for 34 characters before it checks, so a Dutch IBAN never gets its icon. Pass a mask sized from the same table as your validator. The preset keeps its uppercasing and its keyboard settings:

ts
// lib/iban-mask.ts
import { ibanLength } from "@/lib/iban";
// "AA99 **** **** ..." sized to the country, so the field knows when the IBAN is complete
export function ibanMask(raw: string): string {
const length = ibanLength(raw.slice(0, 2)) ?? 34;
let mask = "AA99";
for (let i = 4; i < length; i++) mask += (i % 4 === 0 ? " " : "") + "*";
return mask;
}

The zod rule turns each failed check into its own message and hands your database the electronic format, without spaces:

ts
// lib/iban-schema.ts
import { z } from "zod";
import { checkIban, electronicIban, type IbanProblem } from "@/lib/iban";
const MESSAGES: Record<IbanProblem, string> = {
format: "Enter the full IBAN, starting with the country code, like GB29 NWBK 6016 1331 9268 19",
country: "We can only pay out to banks in SEPA countries",
length: "This IBAN is too short or too long for its country",
checksum: "This IBAN has a typo: the check digits do not match",
};
export const iban = z
.string()
.trim()
.min(1, { error: "Enter your IBAN", abort: true })
.check((ctx) => {
const { problem } = checkIban(ctx.value);
if (problem) ctx.issues.push({ code: "custom", message: MESSAGES[problem], input: ctx.value });
})
.transform(electronicIban);

Wire it with React Hook Form's Controller, because the input element holds the masked text and onValueChange hands you the raw value. Passing validate={isValidIban} makes the field's icon and the zod rule agree on every IBAN:

tsx
"use client";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MaskedInput } from "@/components/ui/masked-input";
import { electronicIban, isValidIban, romanianBank } from "@/lib/iban";
import { ibanMask } from "@/lib/iban-mask";
import { iban } from "@/lib/iban-schema";
const schema = z.object({
holder: z.string().trim().min(1, { error: "Enter the account holder's name" }),
iban,
});
type Values = z.output<typeof schema>;
export function PayoutForm({ onSave }: { onSave: (values: Values) => Promise<void> }) {
const form = useForm<z.input<typeof schema>, unknown, Values>({
resolver: zodResolver(schema),
// the first error waits for blur, then the field re-checks on every keystroke
mode: "onTouched",
defaultValues: { holder: "", iban: "" },
});
const { errors, isSubmitting } = form.formState;
return (
<form noValidate onSubmit={form.handleSubmit(onSave)} className="flex flex-col gap-4">
<Input label="Account holder" autoComplete="name" {...form.register("holder")} error={errors.holder?.message} />
<Controller
control={form.control}
name="iban"
render={({ field, fieldState }) => (
<MaskedInput
preset="iban"
mask={ibanMask}
validate={isValidIban}
label="IBAN"
placeholder="GB29 NWBK 6016 1331 9268 19"
ref={field.ref}
value={field.value}
onValueChange={(raw) => field.onChange(raw)}
onBlur={field.onBlur}
// the mask keeps letters, so a pasted "IBAN:" label would become the country code
onPaste={(event) => {
const text = event.clipboardData.getData("text");
if (!/^\s*iban\b/i.test(text)) return;
event.preventDefault();
field.onChange(electronicIban(text));
}}
// a romanian IBAN names its bank as soon as the bank code is in
description={romanianBank(field.value) ?? "Paste it with or without spaces."}
error={fieldState.error?.message}
/>
)}
/>
<Button type="submit" loading={isSubmitting}>
Save payout details
</Button>
</form>
);
}

The description reads "Banca Transilvania" as soon as someone types RO17BTRL, a quick way for them to confirm they copied the account they meant. The onPaste handler covers the one paste the mask cannot clean up. The field drops every separator but keeps letters, so "IBAN: RO17 BTRL ..." copied from a statement would turn into IB17 BTRL ... and fail with the country message. The handler strips the label with electronicIban before the mask sees it.

If you would rather not write the rule, the Pro form-validation lib has createValidators().iban(), with one message for any failure and no country limit.

How do I show an IBAN so people can copy it?

Display it in groups of four and copy it without spaces. The groups help people read it aloud or compare it with a paper invoice. The clipboard should get the electronic format, because a 34-character IBAN grows to 42 characters with spaces, and a bank form with maxLength={34} cuts the end off on paste.

tsx
import { CopyButton } from "@/components/ui/copy-button";
import { electronicIban, formatIban, romanianBank } from "@/lib/iban";
export function PayoutAccount({ iban }: { iban: string }) {
const bank = romanianBank(iban);
return (
<div className="flex items-center justify-between gap-3 rounded-2xl bg-surface p-4">
<div className="min-w-0">
<p className="font-mono text-sm">{formatIban(iban)}</p>
{bank && <p className="text-xs text-muted-foreground">{bank}</p>}
</div>
<CopyButton value={electronicIban(iban)} copyLabel="Copy IBAN" />
</div>
);
}

Copy Button renders an icon button labeled "Copy IBAN". On desktop its tooltip switches to "Copied"; on a phone the check, a short vibration and a screen reader announcement carry the feedback, and the small size keeps a 44px hit area on touch. The grouped IBAN wraps at its spaces, so it never pushes the row wider than a 360px screen.

Does a valid IBAN mean the account exists?

No. The check digits prove that the IBAN is well formed. A closed account, an account at a bank that has since merged and the wrong person's account all pass. The account holder's name is what catches the last case. Under the EU Instant Payments Regulation, banks must offer Verification of Payee, which compares the payee's name with the account before a credit transfer and returns a result such as match, close match or no match. The ECB's overview of the regulation (opens in a new tab) lists October 9, 2025 for the euro area and July 9, 2027 for the non-euro member states, Romania among them.

That check runs at the payer's bank when the money leaves, long after your form was submitted. So check the digits while people type, and ask for the account holder's name next to the IBAN, as the form above does, so the bank has a name to compare.

Should I use a library like ibantools instead?

Use one when you accept IBANs from every country. As of October 2026, ibantools (opens in a new tab) 4.5.4 (published April 2026, MIT or MPL-2.0) ships the length and account pattern of each registry country, validateIBAN returns error codes you can map to messages, and isValidBIC checks SWIFT codes. It also runs the national check digits some countries add inside the account part, such as Belgium, Spain and France, which the file above skips. One catch for a SEPA form: its isSEPACountry in 4.5.4 still returns false for Albania, Moldova, Montenegro, North Macedonia and Serbia. The iban (opens in a new tab) package, the other common answer, was last published in February 2020. For a SEPA-only form, the file above covers the shape, the length and mod 97 without a dependency, and you can read every line of it.

Should I copy this code or install it?

Copy it when you need one plain field: lib/iban.ts, the zod rule and the free Input cover it. In Wingo UI Pro, Masked Input is the field from the demo, and the invoicing lib exports isValidIban (lengths for 19 countries, while any other country gets the shape and mod 97), formatIban, getIbanBank and a RO_BANKS map of 19 Romanian bank codes, next to the checks from our Romanian CUI validation guide and our Romanian CNP validator. The plans are on the pricing page. Install the field and the free copy button with npx wingo-ui@latest add masked-input copy-button. For the rest of the form anatomy, read React form components: accessible fields, zod and mobile, or browse more component guides.

Components in this post

  • Masked Input

    A text field that formats itself as you type or paste: card number, expiry, CVC, IBAN, CNP, postal code or a custom mask.

    Pro
  • Invoicing

    Invoice math and the ids an invoice carries: VAT per rate the way e-Factura checks it, Romanian VAT rates, CUI, IBAN with its bank, CNP.

    Pro
  • Copy Button

    Copy an IBAN, a tax ID, a link or an API key with one tap: an icon button with a tooltip, a labelled button, and CopyText for inline values.

    Free
  • Input

    The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels.

    Free
  • Form validation

    Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks.

    Pro

FAQ

How do I validate an IBAN in JavaScript?

Remove spaces and dashes, uppercase the string and check its length for the country. Then move the first four characters to the end, replace each letter with its number (A is 10, Z is 35) and compute the remainder mod 97 one digit at a time; the IBAN is valid when the remainder is 1.

What is the regex for an IBAN?

After you remove the spaces and uppercase, /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/ matches the shape of any IBAN, and a country pattern such as ^RO\d{2}[A-Z]{4}[A-Z0-9]{16}$ adds the length. Neither one tests the check digits, so run mod 97 after the regex.

How many characters is a Romanian IBAN?

24: RO, two check digits, four letters for the bank (the first four characters of its BIC) and 16 letters or digits for the branch and the account, for example RO49 AAAA 1B31 0075 9384 0000.

Does a valid IBAN mean the bank account exists?

No. The check digits only prove that the IBAN is well formed, so a closed account or the wrong person's account still passes. In the EU, Verification of Payee at the payer's bank compares the payee's name with the account before a transfer, from October 9, 2025 in the euro area and from July 9, 2027 in Romania and the other non-euro member states.

Why does my IBAN check reject valid IBANs?

Often it is Number or parseInt: the rearranged IBAN has more digits than a JavaScript Number holds exactly, so the remainder comes out wrong. Lowercase letters, non-breaking spaces pasted from a PDF and an IBAN: label in front are the other usual suspects.

How do I find the bank from a Romanian IBAN?

Read characters 5 to 8. They are the first four characters of the bank's BIC, so BTRL is Banca Transilvania and RNCB is BCR. Keep your map current, because merged banks leave old codes behind.

  • IBAN
  • Form Validation
  • TypeScript
  • React
  • Romania
  • Payments

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

Romanian CNP Validator in TypeScript, Zod and React

Build a Romanian CNP validator in TypeScript: the weighted check digit, real birth dates, the new county code 70, a zod rule, a React field and safe storage.

SRSerban Rusu·Oct 9, 2026·13 min read
Component guides

Romanian CUI Validation in TypeScript, Zod and React

Romanian CUI validation in TypeScript: the 753217532 check digit step by step, the RO prefix, a zod rule, a React field and a company lookup at ANAF.

SRSerban Rusu·Oct 9, 2026·11 min read
Component guides

React Form Components: Accessible Fields, Zod and Mobile

React form components that share one anatomy (label, hint, error, counter), plug into react-hook-form and zod, and behave right on a phone, with full code.

SRSerban Rusu·Oct 9, 2026·21 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI