CSV Import React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React CSV import wizard for CSV and Excel files in four steps: upload, column mapping, row review with inline fixes, and a run report.
Drop a CSV, TSV or Excel file on the zone (or click it, press Enter or Space on it, or paste a file; on a phone the native picker opens) and a card shows its size, rows, columns, separator and encoding, or its sheet; turn off "The first row is a header" for files that start with data, pick the sheet of a workbook, or download a template built from the fields. Continue to the mapping: every field already points at the column whose name or alias matches, marked Matched, and headerless files get their CUI, IBAN, CNP, email, phone and date columns recognised by their values; open a field's select (a bottom sheet on phones) to pick another column or Don't import, and picking a column another field uses moves it over. Continue stays disabled until every required field has a column, and the missing ones are named. Rows are checked while you map (big files in idle chunks, so ten thousand never freeze the page), so the review opens ready: filter All, With errors or Valid, read the red wavy underline and the tooltip on a bad cell, click it (or Tab to it, error cells are the only cells in the tab order, and press Enter) to fix it in place, where Enter saves and Escape cancels; the row number, status and skip button stay pinned while the table scrolls sideways. On a phone rows are cards and tapping a value opens a sheet with the field and its error, saved with one button. Skip single rows with the ban button, keep "Skip rows with errors" on to leave the rest out, and choose what happens to rows whose key already exists. Import opens straight on a progress bar that Cancel stops and that screen readers hear every 10%, then counts created, updated, skipped and failed rows in tiles, with a bouncing check when nothing failed, the rows that did not make it with their column and reason, a CSV of them with an Error column, and Import another file to start over; while an import runs, and after it ran, the steps stay where they are.
CSV Import is a Pro React component in the React form components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add csv-import, together with Alert, Badge, Button, Drawer, Empty State and 22 more and the npm packages libphonenumber-js, motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add csv-importCSV Import is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import CsvImport from @/components/ui/csv-import.
import { CsvImport, type ImportField } from "@/components/ui/csv-import"
const FIELDS: ImportField[] = [
{ id: "company", label: "Company", required: true, aliases: ["Company name", "Customer"] },
{ id: "cui", label: "Tax ID (CUI)", type: "cui", aliases: ["CIF", "VAT ID"] },
{ id: "email", label: "Email", type: "email" },
{ id: "phone", label: "Phone", type: "phone" },
{ id: "balance", label: "Balance", type: "currency", aliases: ["Balance (USD)"] },
]
export function ImportCustomers({ existing }: { existing: string[] }) {
return (
<CsvImport
fields={FIELDS}
uniqueField="cui"
existingKeys={existing}
locale="en-US"
dateFormat="MM/DD/YYYY"
labels={{ upload: "File", map: "Columns", review: "Check", import: "Import" }}
onImport={async (rows, { onProgress, signal }) => {
const response = await fetch("/api/customers/import", { method: "POST", body: JSON.stringify(rows), signal })
onProgress(rows.length)
return response.json()
}}
/>
)
}Props
fields*ImportField[]What you import: { id, label, type?, required?, aliases?, options?, validate?, transform?, example?, description?, currency? }. type is text, number, currency, date, email, phone, cui, iban, cnp, boolean or select; aliases are other column names that mean the field; currency is the ISO code of a currency field's amounts (else the component's).
onImport*(rows: Record<string, unknown>[], context: { strategy; onProgress: (done: number) => void; signal: AbortSignal; rows: ImportRow[] }) => Promise<ImportResult>Gets the valid, non-skipped rows with typed values (numbers, ISO dates, E.164 phones, formatted CUI and IBAN). Report progress, stop on the signal, resolve with { created, updated, skipped, failed?: { index, error }[] } where index is the file row from context.rows. A server can re-run the same checks with validateImportRows (see Parts).
defaultStepdefault"upload""upload" | "map" | "review" | "import"The step it opens on. The demo loads its sample file for any step past the upload.
step"upload" | "map" | "review" | "import"The current step, controlled; pair it with onStepChange. A running import holds the wizard on its step.
onStepChange(step: ImportStepId) => voidCalled on every step change.
fileFile | nullThe picked file, controlled. Set it to start from a file you already have (a drop elsewhere on the page, a sample); pair it with onFileChange.
defaultFileFile | nullA file to start with, uncontrolled.
onFileChange(file: File | null) => voidCalled when a file is picked or removed.
acceptdefault".csv,.tsv,.txt,.xlsx"stringFile types the drop zone takes, like the native attribute; the hint under the zone lists them. Old .xls files are refused with errors.unsupported.
maxFileSizedefault"20971520"numberBytes: 20 MB (20 × 1024 × 1024, so the hint reads a round "20 MB"). Bigger files are refused with errors.tooLarge; the hint under the drop zone shows the limit. 0 means no limit.
maxRowsdefault10000numberRows read per sheet. The rest is cut and a warning says so.
uniqueFielddefault""stringThe field that identifies a record (cui, email). Repeats inside the file become errors ("Duplicate of row 12"), rows found in existingKeys get an Exists badge, and the review shows the duplicates choice. The demo falls back to cui.
duplicateStrategy"skip" | "update" | "create"Controlled: what happens to rows whose unique value already exists. Pair it with onDuplicateStrategyChange; the review's select changes it.
defaultDuplicateStrategydefault"skip""skip" | "update" | "create"What happens, while uncontrolled, to rows whose unique value already exists: left out (skip), passed on for an update, or added again (create). The review shows the choice once uniqueField is set; onImport gets it as context.strategy.
onDuplicateStrategyChange(strategy: DuplicateStrategy) => voidCalled when the review's select changes.
existingKeysstring[] | Set<string>Values of uniqueField already in your system (compared without case and diacritics; CUIs with or without RO).
allowSkipInvaliddefaulttruebooleanShows the "Skip rows with errors" switch (on). Off: every error must be fixed, or its row skipped by hand, before Import unlocks.
inlineEditdefaulttruebooleanFix cells in the review: click a cell to edit it in place (a sheet on phones), and it is checked again at once.
defaultHasHeaderdefaulttruebooleanThe starting value of the upload step's "The first row is a header" switch. Off reads the first row as data and names the columns "Column 1"... (labels.columnLabel); CUI, IBAN, CNP, email, phone and date columns are then matched by their values.
templateFileNamedefault"import-template"stringName of the template CSV (the field labels and their examples, ";" separated where the locale writes decimal commas).
delimiterdefault""stringForces the CSV separator. Empty detects it among , ; tab and |.
dateFormatdefault""stringA hint shown next to date fields in the mapping ("MM/DD/YYYY"). Parsing follows the locale, reads dotted dates day first, and accepts ISO and Excel dates.
defaultCountrydefault"RO"stringCountry for phone numbers written without a + prefix (libphonenumber-js). Valid numbers are stored as E.164.
currencydefault"USD"stringISO 4217 code for currency fields in the review ("$12,480.00"); a field's own currency wins. It only labels the amounts, nothing is converted.
stepperdefault"horizontal""horizontal" | "vertical" | "progress" | "dots" | "none"form-wizard's header from 768px up; phones always get the compact progress row.
fullScreenOnMobiledefaulttruebooleanBelow 768px the wizard fills its container with a sticky header and footer and 48px buttons (form-wizard's). The container needs a height.
sizedefault"md""sm" | "md"Stepper markers, footer buttons, selects, switches and the filter.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The active step, the drop zone's drag tint, the switches, the progress bar and the primary button.
localedefault""stringParses and formats numbers, amounts and dates ("12,480.00" and 09/24/2026 in en-US, "12.480,00" and 24.09.2026 in ro-RO). Empty uses UIProvider's locale, then the browser's.
labelsPartial<CsvImportLabels>Every string, English by default: step titles and descriptions, the drop zone (drop, browse, tapTitle, dropHere, fileHint(types, max), fileRejected(names)), template, templateHint, parsing, removeFile, sheet, hasHeader, hasHeaderDescription, columnLabel, rows, columns, delimiter(d), noFile, noFileDescription, chooseFile, the mapping (column, doNotImport, searchColumns, autoMatched, sample, required, usedBy, missingRequired, matched, dateFormat, types), the review (all, errorsFilter, withErrors, valid, skipInvalid, skipInvalidDescription, duplicates, duplicatesDescription, strategies, exists, new, skippedRow, status, errorCount, row, missing, emptyValue, yes, no, skipRow, includeRow, fix, save, cancel, validating, fixToContinue, nothingToImport, noMatches, noMatchesDescription, showMore, tableCaption, toImport), the run (importing(done, total), ready, readySkipped, cancelled, complete, completeWithErrors, completeDescription, importFailed, retry), the summary (created, updated, skipped, failed, failedRows, moreInReport, fieldError(field, error), downloadReport, importMore, reportError, reportFileName), truncated(max), form-wizard's next, back, start, progress and step(index, count), and errors { required, email, phone, cui, iban, cnp, number, date, select, boolean, duplicate(row), tooLarge, unsupported, empty, parse, tooMany }. Counted strings are (count, formatted) => string.
wizardClassNamesFormWizardProps["classNames"]Classes for form-wizard's own parts (header, stepper, body, footer...).
classNamesPartial<Record<"upload" | "dropzone" | "template" | "file" | "sheet" | "header" | "map" | "mapRow" | "mapSample" | "review" | "reviewFilter" | "reviewTable" | "reviewCard" | "duplicates" | "skip" | "run" | "progress" | "summary" | "tile" | "report" | "footer", string>>Extra classes for the inner parts, each also marked with data-slot="csv-import-<part>".
classNamestringClasses for the wizard root (a form); other props spread onto it too.
Parts
validateImportRows
functionvalidateImportRows(rows, fields, mapping, options?) runs the wizard's checks on raw sheet rows without the UI: typed values, raw text and one message per bad cell, plus duplicates. Pure, so an API route can re-check an upload.
rows*readonly (readonly SpreadsheetCell[])[]The data rows of the sheet, header excluded.
fields*readonly ImportField[]The same field list the wizard got.
mapping*ImportMappingField id to column index, null for fields not imported.
optionsValidateImportOptionslocale, errors, uniqueField, existingKeys, firstRowIndex (2 under a header) and override for inline fixes.
validateImportValue
functionvalidateImportValue(field, cell, context?) parses one cell with its field's type and the required check. Returns { value, error }.
IMPORT_VALIDATORS
constantOne pure parser per field type (text, number, currency, date, email, phone, cui, iban, cnp, boolean, select): raw cell in, { value, error } out.
DEFAULT_CSV_IMPORT_LABELS
constantThe English copy of every step and error message. Spread it into labels when you translate only a few keys.
ImportField
typeOne target field: id, label, type, required, aliases (other column names), options for selects, validate, transform, example for the template, description and currency.
Examples
In a dialog
Opened from an Import button, dots as the stepper, starting on the mapping.
Products
A price list: 1,500.00 prices, VAT as 21% / 11% / 0%, units like hr and mo matched through keywords.
No header row
A semicolon separated file without a header: tax ID, email, phone and date columns are found by their values; pick the name column yourself.
Fix before import
Skipping is off: click a red cell to fix it, or skip its row, until Import unlocks.
Summary with a report
Runs on its own: every fifth row fails, and the report holds them with the invalid ones.
10,000 rows
Generate a big file: rows are checked in idle chunks and reviewed 50 at a time.
Phone
At 390px the wizard fills the screen, rows are cards and a tap opens the fix sheet.
Dependencies
Files
- components/ui/csv-import.tsx
FAQ
Is the React CSV Import component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install CSV Import?
Run npx wingo-ui@latest add csv-import. It also adds the Wingo UI items it builds on (Alert, Badge, Button, Drawer, Empty State and 22 more) and the npm packages libphonenumber-js, motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/csv-import.
How does CSV Import behave on phones?
Drop a CSV, TSV or Excel file on the zone (or click it, press Enter or Space on it, or paste a file; on a phone the native picker opens) and a card shows its size, rows, columns, separator and encoding, or its sheet; turn off "The first row is a header" for files that start with data, pick the sheet of a workbook.
See also
More React form components
Articles using this component
- React CSV Importer: Upload, Map Columns, Fix Rows, Import
Component guides7 min read