WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Component guides
  4. Romanian CNP Validator in TypeScript, Zod and React
  1. Blog
  2. Component guides
  3. Romanian CNP Validator in TypeScript, Zod and React
Component guides
Component guides

Romanian CNP Validator in TypeScript, Zod and React

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

On this page

0%
  1. What is the cod numeric personal format?
  2. How does the CNP checksum algorithm work?
  3. How do I validate a CNP in JavaScript or TypeScript?
  4. How do I read the birth date, age and county from a CNP?
  5. How do I write a zod rule for a CNP?
  6. How do I build a CNP field that checks as you type?
  7. Should I collect and store CNPs at all?
  8. Should I use a CNP validator package instead?
  9. Should I copy this code or install it?
  10. FAQ
    1. How do I validate a Romanian CNP?
    2. What does county code 70 mean in a CNP?
    3. Why does the CNP check digit use 1 when the remainder is 10?
    4. Can I get someone's age from their CNP?
    5. Is it legal to store a CNP in my database?
    6. Do I need the customer's CNP for an e-Factura B2C invoice?

TL;DR

A Romanian CNP validator checks five things: 13 digits, a first digit from 1 to 9, a birth date that exists and is not in the future, a county code (01 to 48, 51, 52, or 70, which the new civil status system has used since May 2024) and the check digit. The check digit is the sum of the first 12 digits multiplied by the matching digits of 279146358279, mod 11, with a remainder of 10 written as 1. Store a valid CNP encrypted, with a keyed hash for lookups, because a plain SHA-256 of one can be brute forced in about half an hour on one CPU core.

Published Oct 9, 2026

Your checkout, payroll or rental contract form asks for a Romanian personal numeric code (CNP), and a 13-digit regex accepts every typo. A Romanian CNP validator has to check more than the length: a digit for sex and century, a birth date that exists, a county code and a weighted check digit. This tutorial writes that check in TypeScript, decodes the parts, turns it into a zod rule and a React field that validates as you type, and ends with what to do before you store a single CNP. The field is Masked Input from Wingo UI, the library we build, so we are not neutral. The validator is plain TypeScript you can paste anywhere.

What is the cod numeric personal format?

Thirteen digits in seven parts, written S AA LL ZZ JJ NNN C. The Romanian Wikipedia article on the CNP (opens in a new tab) documents each one. Here they are on 1960523123457, the sample code from the demo further down:

DigitsPartMeaningIn the sample
1SSex and century of birth1: male, born 1900 to 1999
2 to 3AAYear of birth96
4 to 5LLMonth of birth05
6 to 7ZZDay of birth23
8 to 9JJCounty code12: Cluj
10 to 12NNNSerial number, 001 to 999345
13CCheck digit7

From 1 to 8, the first digit is odd for men and even for women. 1 and 2 mean born in the 1900s, 3 and 4 in the 1800s, 5 and 6 in the 2000s. 7 and 8 are foreign residents, and for them the century is not encoded. English Wikipedia also lists 9 for foreign citizens; the Romanian article stops at 8, so treat 9 as rare and accept it.

The county codes are 01 to 39 for the counties in alphabetical order (Alba to Vrancea), 40 for Bucharest, 41 to 46 for its six sectors, 47 and 48 for the former sectors 7 and 8, which only appear on old codes, and 51 and 52 for Călărași and Giurgiu. There is no 49 or 50.

And since May 2024 there is 70. Codes generated by Romania's new civil status IT system carry 70 in place of the county for every registration, wherever the person was born. The Ministry of Internal Affairs said in May 2024 (opens in a new tab) that this started with births registered from May 8 in Ilfov county and seven cities, plus the birth records of people who had just become citizens, and that existing codes do not change. The Romanian Wikipedia article now lists 70 as well. Two consequences for your code: a range check of 01 to 52 rejects every code the new system issues, and you can no longer read a county from a new code.

How does the CNP checksum algorithm work?

The CNP check digit is a weighted sum mod 11. Multiply each of the first 12 digits by the digit in the same position of the key 279146358279, add the products and divide by 11. The remainder is the check digit, except that a remainder of 10 becomes 1. Here is the sample worked by hand:

Position123456789101112
Digit196052312345
Key279146358279
Product2635402012951662845

The products add up to 260, and 260 mod 11 is 7, the last digit of 1960523123457.

The fold of 10 into 1 is where the check gets weaker. Remainders 1 and 10 both produce a 1, so about 2 codes in 11 end in 1, and a typo that moves the sum from one to the other passes. 1960523120031 is valid; change its twelfth digit and 1960523120021 passes too.

We measured it with the checkCnp function below. Over 20,000 random valid codes, we tried every single-digit change and every swap of two neighboring digits. The checksum alone missed 1.7% of the changes, every one of them through the fold, and 1.9% of the swaps. Checking the date and the county as well brought both down to about 1.2%. Of a million random 13-digit strings, 10% passed the checksum and 0.14% passed every check. So about 1 typo in 80 passes every check, which is why the form below also reads the code back to the person.

How do I validate a CNP in JavaScript or TypeScript?

Run the checks in order and say which one failed, so the form can explain the problem instead of printing "invalid". Return the decoded parts from the same pass, so nothing parses the code twice. The code is TypeScript with no dependency; drop the types and it runs as plain JavaScript:

ts
// lib/cnp.ts
export type CnpProblem = "format" | "sex" | "date" | "county" | "checksum";
export type CnpInfo = {
cnp: string;
sex: "male" | "female" | null;
// "1996-05-23"; null for 7 to 9, whose century is not encoded
birthDate: string | null;
// "12"; "70" for codes issued by the civil status system since May 2024
county: string;
countyName: string | null;
serial: string;
};
export type CnpCheck = { problem: CnpProblem; info: null } | { problem: null; info: CnpInfo };
const WEIGHTS = [2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9];
// 01 to 39 in alphabetical order, then Bucharest and its sectors (7 and 8 no longer exist)
const COUNTIES: Record<string, string> = Object.fromEntries(
[
"Alba", "Arad", "Argeș", "Bacău", "Bihor", "Bistrița-Năsăud", "Botoșani", "Brașov", "Brăila", "Buzău",
"Caraș-Severin", "Cluj", "Constanța", "Covasna", "Dâmbovița", "Dolj", "Galați", "Gorj", "Harghita", "Hunedoara",
"Ialomița", "Iași", "Ilfov", "Maramureș", "Mehedinți", "Mureș", "Neamț", "Olt", "Prahova", "Satu Mare",
"Sălaj", "Sibiu", "Suceava", "Teleorman", "Timiș", "Tulcea", "Vaslui", "Vâlcea", "Vrancea", "București",
"București, Sector 1", "București, Sector 2", "București, Sector 3", "București, Sector 4",
"București, Sector 5", "București, Sector 6", "București, Sector 7", "București, Sector 8",
].map((name, i) => [String(i + 1).padStart(2, "0"), name]),
);
COUNTIES["51"] = "Călărași";
COUNTIES["52"] = "Giurgiu";
const CENTURY: Record<string, number> = { "1": 1900, "2": 1900, "3": 1800, "4": 1800, "5": 2000, "6": 2000 };
export function cnpCheckDigit(first12: string): number {
let sum = 0;
for (let i = 0; i < 12; i++) sum += Number(first12[i]) * WEIGHTS[i];
// a remainder of 10 does not fit in one digit, so it becomes 1
return sum % 11 === 10 ? 1 : sum % 11;
}
// "1996-05-23", or null when that day does not exist
function isoDate(year: number, month: number, day: number): string | null {
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
return date.toISOString().slice(0, 10);
}
// the calendar day in Romania, where the codes are issued
export const todayInRomania = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Bucharest" }).format(new Date());
export function checkCnp(input: string, today = todayInRomania()): CnpCheck {
const cnp = input.replace(/[\s.-]/g, "");
const fail = (problem: CnpProblem): CnpCheck => ({ problem, info: null });
if (!/^\d{13}$/.test(cnp)) return fail("format");
const s = cnp[0];
if (s === "0") return fail("sex");
const [yy, mm, dd] = [cnp.slice(1, 3), cnp.slice(3, 5), cnp.slice(5, 7)].map(Number);
let birthDate: string | null = null;
if (CENTURY[s]) {
birthDate = isoDate(CENTURY[s] + yy, mm, dd);
if (!birthDate || birthDate > today) return fail("date");
} else {
// 7 to 9: the day has to exist in one of the two centuries it could be from
const candidates = [2000 + yy, 1900 + yy].map((year) => isoDate(year, mm, dd));
if (!candidates.some((date) => date && date <= today)) return fail("date");
}
const county = cnp.slice(7, 9);
if (county !== "70" && !COUNTIES[county]) return fail("county");
if (cnpCheckDigit(cnp) !== Number(cnp[12])) return fail("checksum");
return {
problem: null,
info: {
cnp,
sex: s === "9" ? null : Number(s) % 2 === 1 ? "male" : "female",
birthDate,
county,
countyName: COUNTIES[county] ?? null,
serial: cnp.slice(9, 12),
},
};
}
export const isValidCnp = (input: string) => checkCnp(input).problem === null;
ts
checkCnp("1960 523 123 457");
// { problem: null, info: { cnp: "1960523123457", sex: "male", birthDate: "1996-05-23", county: "12", countyName: "Cluj", serial: "345" } }
checkCnp("1960523123458"); // { problem: "checksum", info: null }
checkCnp("1000229123456"); // { problem: "date", info: null }, 1900 was not a leap year
checkCnp("1960523491239"); // { problem: "county", info: null }, there is no county 49
isValidCnp("5240601701230"); // true, county 70

Four details that CNP validation in JavaScript often gets wrong:

  • The century comes from the first digit. new Date(24, 5, 1) is June 1, 1924, because JavaScript maps two-digit years to the 1900s. Build the full year first, and check that the day survived the round trip, or February 29, 1900 slips through.
  • A birth date cannot be in the future. 5300101123456 decodes to January 1, 2030. Compare against today in Romania, not in UTC or in the server's zone.
  • The county list has gaps and a new member. 49 and 50 do not exist, and 70 does.
  • Codes starting with 7 to 9 have no century. We accept the day if it exists in either century and return birthDate: null instead of guessing, because a wrong guess turns an 11-year-old resident into a 111-year-old.

How do I read the birth date, age and county from a CNP?

Take them from info, and turn them into a sentence the person can compare with their ID card. Reading a valid code back catches the mistake no checksum can: someone typing a valid code that belongs to their spouse.

ts
// lib/cnp.ts (continued)
// "Male, born May 23, 1996, Cluj"
export function describeCnp(info: CnpInfo, locale = "en-US"): string {
const sex = info.sex === "male" ? "Male" : info.sex === "female" ? "Female" : "Foreign national";
const born = info.birthDate
? `born ${new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(info.birthDate))}`
: "resident";
return [sex, born, info.countyName].filter(Boolean).join(", ");
}
// full years on a given day: ageOn("2008-10-10", "2026-10-09") is 17
export function ageOn(birthDate: string, today = todayInRomania()): number {
const [by, bm, bd] = birthDate.split("-").map(Number);
const [ty, tm, td] = today.split("-").map(Number);
return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0);
}

A code with county 70 reads "Male, born June 1, 2024", without a county, which is the truth.

How do I write a zod rule for a CNP?

Wrap checkCnp in a check that maps each problem to its own message, then strip the separators so the database gets the 13 digits. The schema uses zod 4 (.check() and the error param):

ts
// lib/cnp-schema.ts
import { z } from "zod";
import { ageOn, checkCnp, type CnpProblem } from "@/lib/cnp";
const MESSAGES: Record<CnpProblem, string> = {
format: "A CNP has 13 digits",
sex: "A CNP starts with a digit from 1 to 9",
date: "Digits 2 to 7 are not a real birth date",
county: "Digits 8 and 9 are not a county code",
checksum: "The last digit does not match. Check for a typo",
};
export const cnp = z
.string()
.trim()
.min(1, { error: "Enter your CNP", abort: true })
.check((ctx) => {
const { problem } = checkCnp(ctx.value);
if (problem) ctx.issues.push({ code: "custom", message: MESSAGES[problem], input: ctx.value });
})
.transform((value) => value.replace(/[\s.-]/g, ""));
// for contracts that need an adult; 7 to 9 have no birth date, so ask for one separately
export const adultCnp = cnp.refine(
(value) => {
const { info } = checkCnp(value);
return !info?.birthDate || ageOn(info.birthDate) >= 18;
},
{ error: "You need to be 18 or older" },
);

"Digits 2 to 7 are not a real birth date" tells the person where to look, which "Invalid CNP" never does. If you use the form-validation lib from Wingo UI Pro, it has rules for CUI and IBAN but none for CNP, so this rule sits next to them: with const v = createValidators(), write z.object({ email: v.email(), iban: v.iban(), cnp }).

How do I build a CNP field that checks as you type?

Use a masked field with a numeric keypad, and show the verdict the moment the 13th digit lands. The cnp preset of Masked Input sets inputMode="numeric" and autoComplete="off", keeps the value to 13 digits, drops spaces, dots and dashes from a paste, and pops in a check or an alert icon once the code is complete. The red ring and aria-invalid wait until the person leaves the field. The preset's own check accepts any county from 01 to 52, so it lets 49 and 50 through and rejects 70. Pass validate={isValidCnp} and the field agrees with the zod rule on every code.

Type 1960523123457: a check appears once the 13th digit is in. Change the last digit and it turns into an alert; press Save to see the error message
$ npx wingo-ui@latest add masked-input
ProMasked Input docs

Wire it with React Hook Form's Controller (npm install react-hook-form @hookform/resolvers zod). Use Controller, not register: the input element holds the masked text, and the raw digits come through onValueChange:

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 { MaskedInput } from "@/components/ui/masked-input";
import { checkCnp, describeCnp, isValidCnp } from "@/lib/cnp";
import { cnp } from "@/lib/cnp-schema";
const schema = z.object({ cnp });
type Values = z.output<typeof schema>;
export function IdentityStep({ 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: { cnp: "" },
});
return (
<form noValidate onSubmit={form.handleSubmit(onSave)} className="flex flex-col gap-4">
<Controller
control={form.control}
name="cnp"
render={({ field, fieldState }) => {
const { info } = checkCnp(field.value);
return (
<MaskedInput
preset="cnp"
// the same check as the zod rule, so the icon and the error never disagree
validate={isValidCnp}
label="CNP (personal numeric code)"
placeholder="13 digits"
showMask="focus"
placeholderChar="·"
ref={field.ref}
value={field.value}
onValueChange={(raw) => field.onChange(raw)}
onBlur={field.onBlur}
// read back, so a valid code that is not theirs stands out
description={info ? describeCnp(info) : "The 13 digits on your ID card."}
error={fieldState.error?.message}
enterKeyHint="done"
/>
);
}}
/>
<Button type="submit" loading={form.formState.isSubmitting}>
Continue
</Button>
</form>
);
}

The field uses 16px text on phones, so iOS does not zoom into it, and grows to 44px on touch screens. Its error replaces the description, sets aria-invalid and links the message with aria-describedby. For onSave, pass a Server Action that stores the code with the vault in the next section; the CNP then travels in a POST body, never in a URL.

Should I collect and store CNPs at all?

Only when a law or a contract needs one. A CNP gives away a birth date and a sex, and on codes issued before 2024 a county, so a leak of it is worse than a leak of an email address. If you only need an age, ask for the birth date. If you send B2C invoices through e-Factura, you do not need it either: since OUG 138/2024 a business may use 0000000000000 when a private buyer gives no tax code, and it is not required to collect CNPs (opens in a new tab). Accept those 13 zeros before you call checkCnp if the field feeds an invoice.

The check under the invoice reads 0000000000000 as a buyer who gave no CNP, the placeholder e-Factura accepts on B2C invoices
$ npx wingo-ui@latest add invoicing
ProInvoicing docs

When you do collect it, Romanian Law 190/2018 (opens in a new tab) applies on top of GDPR. Its article 2 lists the CNP as a national identification number, and article 4 sets the rules for processing one. Processing it needs a GDPR legal basis, and when that basis is legitimate interest the law requires four safeguards: technical and organizational measures for data minimization and security, a data protection officer, storage periods with deadlines for deletion, and regular training for the people who handle it. This is not legal advice; your DPO decides what applies to you.

What engineering can do is keep the code out of every place it does not need to be. Start with the storage, because a hash is not anonymization. The space of valid codes is small. Everyone born in the 1900s fits in about 3.6 billion candidates: 36,524 days, 2 sex digits, 50 county codes and 999 serials, with the check digit computed. On one core of our dev server, Node's createHash hashed about 2 million codes a second, so walking the whole century takes about half an hour. Knowing the birth date leaves 99,900 candidates, about 50 milliseconds of hashing.

So store two things: the code encrypted, and a keyed hash for lookups and unique indexes. The key never lives in the database:

ts
// lib/cnp-vault.ts
import "server-only";
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";
// two 32 byte keys, base64, from your secret manager: openssl rand -base64 32
// read on use, not at import, so next build works on a machine without the secrets
function key(name: "CNP_ENCRYPTION_KEY" | "CNP_LOOKUP_KEY"): Buffer {
const value = Buffer.from(process.env[name] ?? "", "base64");
if (value.length !== 32) throw new Error(`${name} must be 32 bytes, base64`);
return value;
}
// same CNP, same value: for unique indexes and "is this person already a customer"
export function cnpLookupHash(cnp: string): string {
return createHmac("sha256", key("CNP_LOOKUP_KEY")).update(cnp).digest("base64url");
}
// iv + auth tag + ciphertext in one string; a new iv every time, so equal CNPs never look equal
export function encryptCnp(cnp: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key("CNP_ENCRYPTION_KEY"), iv);
const data = Buffer.concat([cipher.update(cnp, "utf8"), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64");
}
export function decryptCnp(stored: string): string {
const raw = Buffer.from(stored, "base64");
const decipher = createDecipheriv("aes-256-gcm", key("CNP_ENCRYPTION_KEY"), raw.subarray(0, 12));
decipher.setAuthTag(raw.subarray(12, 28));
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
}
// what support screens and receipts show: "•••••••••3457"
export const maskCnp = (cnp: string) => "•".repeat(9) + cnp.slice(-4);

The masked form keeps the serial and the check digit and hides the sex, the birth date and the county. The rest is a checklist:

  • Never in a URL. Send the code in a POST body. Query strings end up in access logs, browser history and analytics.
  • Never in logs or error reports. Redact cnp in your logger and in your error tracker's event filter, and check that session replay masks the input.
  • Never back to the browser. After the save, the client gets maskCnp, and only a server route that needs the full code calls decryptCnp.
  • A deletion date. Decide when each stored code is deleted, and run the job that deletes it.

Should I use a CNP validator package instead?

Only after you read its county table. Code 70 is recent, and a package that misses it rejects real codes. As of October 2026, the most downloaded CNP package on npm, romanian-personal-identity-code-validator (opens in a new tab), is at 1.1.4 (published May 19, 2024, MIT, 667 downloads from October 1 to 7, 2026). It decodes the birth date, sex, birthplace and age, depends on Moment.js, and its county table has no 70, so it rejects every code the new system issues. It does list 80, as a code obtained abroad. python-stdnum's ro.cnp module (opens in a new tab) accepts 70, and also 80 to 83, labeled "Unknown". We found no official source for 80 to 83, so the code above leaves them out. If a real customer's code fails on one of them, add it.

lib/cnp.ts is about 100 lines with no dependency, and you can read every rule in it. When the format changes again, you edit one table instead of waiting for a maintainer.

Should I copy this code or install it?

Copy it when you need one field: lib/cnp.ts, the zod rule and the free Input with inputMode="numeric" cover a contract or payroll form. Masked Input, the field from the demo, is part of Wingo UI Pro, and so is the invoicing lib, whose parseCnp and isValidCnp decode the sex, birth date and county, accept 70 and treat 0000000000000 as a buyer without a CNP. They skip the future-date check, so keep checkCnp for the form. Install the field with npx wingo-ui@latest add masked-input.

The other Romanian ids have their own guides: Romanian CUI validation for companies and IBAN validation in JavaScript for bank accounts. 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
  • 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
  • Input

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

    Free

FAQ

How do I validate a Romanian CNP?

Check that it has 13 digits, that the first digit is 1 to 9, that digits 2 to 7 form a real birth date that is not in the future, and that digits 8 and 9 are a county code (01 to 48, 51, 52 or 70). Then multiply the first 12 digits by 2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9, add the products and take the sum mod 11, turning 10 into 1; the result must equal the last digit.

What does county code 70 mean in a CNP?

Since May 2024, the codes generated by Romania's new civil status IT system carry 70 in place of the county, whatever the place of birth. Codes issued before keep their county, so a validator that only accepts 01 to 52 rejects every code the new system issues.

Why does the CNP check digit use 1 when the remainder is 10?

The check digit has room for one digit, so a remainder of 10 is written as 1. The cost is that remainders 1 and 10 look the same, so a typo that moves the sum between them still passes; in our test that was 1.7% of single-digit typos.

Can I get someone's age from their CNP?

For codes starting with 1 to 6, yes: the first digit gives the century and digits 2 to 7 the year, month and day. Codes for foreign residents and foreign citizens start with 7, 8 or 9 and do not encode the century, so ask for the birth date when the age matters.

Is it legal to store a CNP in my database?

Romanian Law 190/2018 lists the CNP as a national identification number: you need a GDPR legal basis, and if that basis is legitimate interest you also need data minimization and security measures, a data protection officer, retention periods and staff training. Collect it only when a law or a contract needs it, and ask your DPO before you do.

Do I need the customer's CNP for an e-Factura B2C invoice?

No. Since OUG 138/2024, a business may use 0000000000000, thirteen zeros, when a private buyer gives no tax identification code, and it is not required to collect the CNP.

  • CNP
  • Romania
  • Form Validation
  • Zod
  • TypeScript
  • Privacy

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

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

IBAN validation JavaScript that catches typos: mod 97 in TypeScript, why a regex is not enough, a React field that formats as you type and Romanian banks.

SRSerban Rusu·Oct 9, 2026·12 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