# CSV Import

> 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.

- Type: React component (Forms)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add csv-import`
- Docs: https://wingo-ui.com/components/csv-import

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add csv-import
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/csv-import` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/csv-import.tsx`

npm dependencies: `npm i libphonenumber-js motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Alert](https://wingo-ui.com/components/alert) ([Markdown](https://wingo-ui.com/components/alert.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [Form Wizard](https://wingo-ui.com/components/form-wizard) ([Markdown](https://wingo-ui.com/components/form-wizard.md))
- [Inline Edit](https://wingo-ui.com/components/inline-edit) ([Markdown](https://wingo-ui.com/components/inline-edit.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [File types](https://wingo-ui.com/components/file-types) ([Markdown](https://wingo-ui.com/components/file-types.md))
- [Invoicing](https://wingo-ui.com/components/invoicing) ([Markdown](https://wingo-ui.com/components/invoicing.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Spreadsheet](https://wingo-ui.com/components/spreadsheet) ([Markdown](https://wingo-ui.com/components/spreadsheet.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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). |
| `defaultStep?` | `"upload" \| "map" \| "review" \| "import"` | `"upload"` | 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) => void` |  | Called on every step change. |
| `file?` | `File \| null` |  | The 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. |
| `defaultFile?` | `File \| null` |  | A file to start with, uncontrolled. |
| `onFileChange?` | `(file: File \| null) => void` |  | Called when a file is picked or removed. |
| `accept?` | `string` | `".csv,.tsv,.txt,.xlsx"` | File types the drop zone takes, like the native attribute; the hint under the zone lists them. Old .xls files are refused with errors.unsupported. |
| `maxFileSize?` | `number` | `"20971520"` | Bytes: 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. |
| `maxRows?` | `number` | `10000` | Rows read per sheet. The rest is cut and a warning says so. |
| `uniqueField?` | `string` | `""` | The 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. |
| `defaultDuplicateStrategy?` | `"skip" \| "update" \| "create"` | `"skip"` | 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) => void` |  | Called when the review's select changes. |
| `existingKeys?` | `string[] \| Set<string>` |  | Values of uniqueField already in your system (compared without case and diacritics; CUIs with or without RO). |
| `allowSkipInvalid?` | `boolean` | `true` | Shows the "Skip rows with errors" switch (on). Off: every error must be fixed, or its row skipped by hand, before Import unlocks. |
| `inlineEdit?` | `boolean` | `true` | Fix cells in the review: click a cell to edit it in place (a sheet on phones), and it is checked again at once. |
| `defaultHasHeader?` | `boolean` | `true` | The 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. |
| `templateFileName?` | `string` | `"import-template"` | Name of the template CSV (the field labels and their examples, ";" separated where the locale writes decimal commas). |
| `delimiter?` | `string` | `""` | Forces the CSV separator. Empty detects it among , ; tab and \|. |
| `dateFormat?` | `string` | `""` | A 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. |
| `defaultCountry?` | `string` | `"RO"` | Country for phone numbers written without a + prefix (libphonenumber-js). Valid numbers are stored as E.164. |
| `currency?` | `string` | `"USD"` | ISO 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. |
| `stepper?` | `"horizontal" \| "vertical" \| "progress" \| "dots" \| "none"` | `"horizontal"` | form-wizard's header from 768px up; phones always get the compact progress row. |
| `fullScreenOnMobile?` | `boolean` | `true` | Below 768px the wizard fills its container with a sticky header and footer and 48px buttons (form-wizard's). The container needs a height. |
| `size?` | `"sm" \| "md"` | `"md"` | Stepper markers, footer buttons, selects, switches and the filter. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The active step, the drop zone's drag tint, the switches, the progress bar and the primary button. |
| `locale?` | `string` | `""` | Parses 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. |
| `labels?` | `Partial<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. |
| `wizardClassNames?` | `FormWizardProps["classNames"]` |  | Classes for form-wizard's own parts (header, stepper, body, footer...). |
| `classNames?` | `Partial<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>". |
| `className?` | `string` |  | Classes for the wizard root (a form); other props spread onto it too. |

## Parts

### validateImportRows (function)

validateImportRows(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.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `rows` | `readonly (readonly SpreadsheetCell[])[]` |  | The data rows of the sheet, header excluded. |
| `fields` | `readonly ImportField[]` |  | The same field list the wizard got. |
| `mapping` | `ImportMapping` |  | Field id to column index, null for fields not imported. |
| `options?` | `ValidateImportOptions` |  | locale, errors, uniqueField, existingKeys, firstRowIndex (2 under a header) and override for inline fixes. |

### validateImportValue (function)

validateImportValue(field, cell, context?) parses one cell with its field's type and the required check. Returns { value, error }.

### IMPORT_VALIDATORS (constant)

One 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 (constant)

The English copy of every step and error message. Spread it into labels when you translate only a few keys.

### ImportField (type)

One 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.

## See also

- [Spreadsheet](https://wingo-ui.com/components/spreadsheet) ([Markdown](https://wingo-ui.com/components/spreadsheet.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [Form Wizard](https://wingo-ui.com/components/form-wizard) ([Markdown](https://wingo-ui.com/components/form-wizard.md))
- [Data Table](https://wingo-ui.com/components/data-table) ([Markdown](https://wingo-ui.com/components/data-table.md))
- [Invoicing](https://wingo-ui.com/components/invoicing) ([Markdown](https://wingo-ui.com/components/invoicing.md))

---

Canonical: https://wingo-ui.com/components/csv-import
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
