Data import UX: designing spreadsheet imports users don't hate

You're filling out a lengthy form on a website. You've entered data into dozens of fields. The final step is uploading a CSV file. You select your file, click upload, and receive an error: "Invalid file format." The entire form resets. Your data is gone.
This scenario plays out thousands of times daily across web applications. Poor data import UX doesn't just frustrate users—it drives them to abandon your product entirely. When data import is core to your application's value (CRM systems, analytics tools, inventory management), bad import UX directly impacts retention.
This guide covers the UX patterns that make data imports feel effortless, from the initial file upload through validation and confirmation. You'll learn practical design principles with code examples you can adapt for your own applications.
Prerequisites
Before implementing these patterns, you should have:
- React 18+ and Next.js 14+
- Basic TypeScript knowledge
- Familiarity with file handling in JavaScript
The five stages of data import UX
Every data import flow, regardless of complexity, follows five stages. Understanding this framework helps you design each stage intentionally rather than leaving users to figure things out.
Stage 1: Pre-import
Set expectations before users select a file. Show allowed file types, size limits, and required columns. Provide a downloadable template.
Stage 2: Upload
Accept files through multiple methods: drag-and-drop, click-to-browse, or paste. Show clear progress feedback during upload.
Stage 3: Mapping
Match uploaded columns to your data schema. Auto-match when possible, but allow manual corrections.
Stage 4: Validation
Check data quality in real-time. Highlight errors with specific, actionable messages. Let users fix problems inline.
Stage 5: Confirmation
Show a summary before committing. Let users review what will be imported and provide a clear path to completion.
The most common mistake is focusing only on stages 2 and 5 (upload and confirm) while neglecting mapping and validation. Users who encounter errors at the confirmation stage feel like they wasted their time—they have to start over or guess at what went wrong.
Pre-import: setting users up for success
The pre-import stage happens before users select a file. Your goal is to eliminate surprises by communicating requirements upfront.
Keep instructions scannable
Research from Smashing Magazine suggests keeping import instructions under 100 words. Users don't read walls of text—they scan for the information they need.
// Bad: Wall of text
<div>
<p>
To import your data, you'll need to prepare a CSV file that contains
the following columns: email (required), first name (optional), last
name (optional), and phone number (optional). The file must be in CSV
format with UTF-8 encoding. Maximum file size is 10MB. The first row
should contain column headers. Empty rows will be skipped...
</p>
</div>
// Good: Scannable format
<div className="import-requirements">
<h3>File requirements</h3>
<ul>
<li>Format: CSV</li>
<li>Max size: 10MB</li>
<li>Required column: email</li>
</ul>
<a href="/template.csv" download>Download template</a>
</div>Provide a template file
A downloadable template removes guesswork about column names, order, and formatting. Include sample data so users understand expected values.
function ImportPreview() {
return (
<div className="pre-import">
<div className="template-download">
<p>Not sure how to format your file?</p>
<a
href="/api/template/contacts.csv"
download="contact-import-template.csv"
className="download-link"
>
Download CSV template
</a>
</div>
<div className="requirements">
<h4>Required columns</h4>
<table>
<thead>
<tr>
<th>Column</th>
<th>Format</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>email</td>
<td>Valid email address</td>
<td>jane@example.com</td>
</tr>
<tr>
<td>name</td>
<td>Text</td>
<td>Jane Smith</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}Upload UX: make it obvious
The upload stage is where users interact with your import interface directly. The goal is to make file selection feel natural and provide immediate feedback.
Support multiple input methods
Not all users interact with file uploads the same way. Desktop users often prefer drag-and-drop. Mobile users need tap-to-browse. Power users may want to paste data directly.
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
interface FileUploadProps {
onFileAccepted: (file: File) => void;
maxSizeMB?: number;
}
export function FileUpload({ onFileAccepted, maxSizeMB = 10 }: FileUploadProps) {
const [error, setError] = useState<string | null>(null);
const maxSize = maxSizeMB * 1024 * 1024;
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: any[]) => {
setError(null);
if (rejectedFiles.length > 0) {
const rejection = rejectedFiles[0];
const errorCode = rejection.errors[0]?.code;
if (errorCode === "file-too-large") {
setError(`File exceeds ${maxSizeMB}MB limit`);
} else if (errorCode === "file-invalid-type") {
setError("Please upload a CSV file");
} else {
setError("File could not be accepted");
}
return;
}
if (acceptedFiles[0]) {
onFileAccepted(acceptedFiles[0]);
}
},
[onFileAccepted, maxSizeMB]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "text/csv": [".csv"] },
maxSize,
multiple: false,
});
return (
<div>
<div
{...getRootProps()}
style={{
border: `2px dashed ${isDragActive ? "#3b82f6" : "#d1d5db"}`,
borderRadius: "8px",
padding: "48px 24px",
textAlign: "center",
cursor: "pointer",
backgroundColor: isDragActive ? "#eff6ff" : "#fafafa",
transition: "all 0.15s ease",
}}
>
<input {...getInputProps()} />
<div style={{ marginBottom: "12px" }}>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="#9ca3af"
strokeWidth="1.5"
style={{ margin: "0 auto" }}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
{isDragActive ? (
<p style={{ color: "#3b82f6", margin: 0 }}>
Drop your CSV file here
</p>
) : (
<>
<p style={{ color: "#374151", margin: "0 0 4px" }}>
Drag and drop your CSV file here
</p>
<p style={{ color: "#6b7280", fontSize: "14px", margin: 0 }}>
or click to browse
</p>
</>
)}
<p style={{ color: "#9ca3af", fontSize: "12px", marginTop: "16px" }}>
CSV files up to {maxSizeMB}MB
</p>
</div>
{error && (
<p style={{ color: "#dc2626", fontSize: "14px", marginTop: "8px" }}>
{error}
</p>
)}
</div>
);
}The dashed border pattern
The dashed border has become a standard visual affordance for drag-and-drop zones. Users recognize it instantly. When a file is dragged over the zone, change the border color and background to provide feedback that the drop will work.
Show progress for uploads
For files that take more than a second to process, show progress. Uncertainty about whether an upload is working creates anxiety.
interface UploadProgressProps {
fileName: string;
progress: number; // 0-100
}
function UploadProgress({ fileName, progress }: UploadProgressProps) {
return (
<div style={{ padding: "16px", backgroundColor: "#f9fafb", borderRadius: "8px" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
<span style={{ fontSize: "14px", color: "#374151" }}>{fileName}</span>
<span style={{ fontSize: "14px", color: "#6b7280" }}>{progress}%</span>
</div>
<div style={{
height: "4px",
backgroundColor: "#e5e7eb",
borderRadius: "2px",
overflow: "hidden"
}}>
<div
style={{
height: "100%",
width: `${progress}%`,
backgroundColor: "#3b82f6",
transition: "width 0.2s ease"
}}
/>
</div>
</div>
);
}Column mapping UX
Column mapping is where users connect their CSV columns to your application's data schema. This stage often determines whether an import succeeds or fails.
Auto-match with confidence indicators
Auto-matching columns saves users time, but they need to understand what happened. Show which columns were matched automatically and let users verify or correct the matches.
interface ColumnMapping {
sourceColumn: string;
targetField: string | null;
confidence: "high" | "medium" | "low" | "manual";
sampleValues: string[];
}
interface ColumnMapperProps {
mappings: ColumnMapping[];
targetFields: { key: string; label: string; required: boolean }[];
onMappingChange: (sourceColumn: string, targetField: string | null) => void;
}
function ColumnMapper({ mappings, targetFields, onMappingChange }: ColumnMapperProps) {
const getConfidenceColor = (confidence: ColumnMapping["confidence"]) => {
switch (confidence) {
case "high": return "#22c55e";
case "medium": return "#eab308";
case "low": return "#f97316";
case "manual": return "#6b7280";
}
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<div style={{
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
gap: "16px",
padding: "12px",
backgroundColor: "#f9fafb",
borderRadius: "8px",
fontWeight: 600,
fontSize: "14px"
}}>
<span>Your CSV column</span>
<span></span>
<span>Maps to</span>
</div>
{mappings.map((mapping) => (
<div
key={mapping.sourceColumn}
style={{
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
gap: "16px",
alignItems: "center",
padding: "12px",
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "8px"
}}
>
<div>
<div style={{ fontWeight: 500 }}>{mapping.sourceColumn}</div>
<div style={{ fontSize: "12px", color: "#6b7280", marginTop: "4px" }}>
{mapping.sampleValues.slice(0, 2).join(", ")}
{mapping.sampleValues.length > 2 && "..."}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: getConfidenceColor(mapping.confidence)
}}
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#9ca3af">
<path d="M5 12h14M12 5l7 7-7 7" strokeWidth="2" />
</svg>
</div>
<select
value={mapping.targetField || ""}
onChange={(e) => onMappingChange(
mapping.sourceColumn,
e.target.value || null
)}
style={{
padding: "8px 12px",
border: "1px solid #d1d5db",
borderRadius: "6px",
fontSize: "14px",
backgroundColor: "#ffffff"
}}
>
<option value="">Do not import</option>
{targetFields.map((field) => (
<option key={field.key} value={field.key}>
{field.label} {field.required && "*"}
</option>
))}
</select>
</div>
))}
<div style={{ fontSize: "12px", color: "#6b7280", display: "flex", gap: "16px" }}>
<span><span style={{ color: "#22c55e" }}>●</span> High confidence</span>
<span><span style={{ color: "#eab308" }}>●</span> Medium</span>
<span><span style={{ color: "#f97316" }}>●</span> Low</span>
<span><span style={{ color: "#6b7280" }}>●</span> Manual</span>
</div>
</div>
);
}Show sample data
Displaying sample values from each column helps users verify that mappings are correct. If a column labeled "Phone" shows email addresses, something is wrong—and users can catch it before importing.
Validation UX: help users fix problems
Validation is where most import experiences fall apart. Generic error messages like "Invalid data" or "Import failed" leave users guessing. Good validation UX tells users exactly what's wrong and how to fix it.
Use color to communicate status
Color provides instant visual feedback about data quality:
- Green: Valid, ready to import
- Orange/Yellow: Warning, may need attention
- Red: Error, requires fixing before import
interface ValidationRow {
rowNumber: number;
data: Record<string, string>;
errors: { field: string; message: string }[];
warnings: { field: string; message: string }[];
}
interface ValidationTableProps {
rows: ValidationRow[];
columns: string[];
showOnlyErrors: boolean;
onShowOnlyErrorsChange: (value: boolean) => void;
onCellEdit: (rowNumber: number, field: string, value: string) => void;
}
function ValidationTable({
rows,
columns,
showOnlyErrors,
onShowOnlyErrorsChange,
onCellEdit
}: ValidationTableProps) {
const filteredRows = showOnlyErrors
? rows.filter(r => r.errors.length > 0)
: rows;
const errorCount = rows.filter(r => r.errors.length > 0).length;
const warningCount = rows.filter(r => r.warnings.length > 0 && r.errors.length === 0).length;
const getCellStyle = (row: ValidationRow, field: string) => {
const hasError = row.errors.some(e => e.field === field);
const hasWarning = row.warnings.some(w => w.field === field);
return {
padding: "8px 12px",
border: "1px solid #e5e7eb",
backgroundColor: hasError ? "#fef2f2" : hasWarning ? "#fffbeb" : "#ffffff",
};
};
return (
<div>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "16px"
}}>
<div style={{ display: "flex", gap: "16px", fontSize: "14px" }}>
{errorCount > 0 && (
<span style={{ color: "#dc2626" }}>
{errorCount} row{errorCount !== 1 && "s"} with errors
</span>
)}
{warningCount > 0 && (
<span style={{ color: "#d97706" }}>
{warningCount} row{warningCount !== 1 && "s"} with warnings
</span>
)}
{errorCount === 0 && warningCount === 0 && (
<span style={{ color: "#16a34a" }}>All rows valid</span>
)}
</div>
{errorCount > 0 && (
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "14px" }}>
<input
type="checkbox"
checked={showOnlyErrors}
onChange={(e) => onShowOnlyErrorsChange(e.target.checked)}
/>
Show only rows with errors
</label>
)}
</div>
<div style={{ overflowX: "auto" }}>
<table style={{ borderCollapse: "collapse", width: "100%", fontSize: "14px" }}>
<thead>
<tr>
<th style={{
padding: "10px 12px",
backgroundColor: "#f9fafb",
border: "1px solid #e5e7eb",
textAlign: "left",
width: "60px"
}}>
Row
</th>
{columns.map(col => (
<th key={col} style={{
padding: "10px 12px",
backgroundColor: "#f9fafb",
border: "1px solid #e5e7eb",
textAlign: "left"
}}>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{filteredRows.map((row) => (
<tr key={row.rowNumber}>
<td style={{
padding: "8px 12px",
border: "1px solid #e5e7eb",
color: "#6b7280",
fontSize: "12px"
}}>
{row.rowNumber}
</td>
{columns.map(col => {
const error = row.errors.find(e => e.field === col);
const warning = row.warnings.find(w => w.field === col);
return (
<td key={col} style={getCellStyle(row, col)}>
<input
type="text"
value={row.data[col] || ""}
onChange={(e) => onCellEdit(row.rowNumber, col, e.target.value)}
style={{
width: "100%",
border: "none",
backgroundColor: "transparent",
padding: "0",
fontSize: "14px"
}}
/>
{error && (
<div style={{ color: "#dc2626", fontSize: "12px", marginTop: "4px" }}>
{error.message}
</div>
)}
{!error && warning && (
<div style={{ color: "#d97706", fontSize: "12px", marginTop: "4px" }}>
{warning.message}
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}Provide specific, actionable error messages
Compare these error messages:
| Bad | Good |
|---|---|
| "Invalid email" | "Row 5: 'john@' is not a valid email address" |
| "Required field missing" | "Row 12: Email is required but empty" |
| "Invalid format" | "Row 8: Date must be YYYY-MM-DD format (received '1/15/24')" |
| "Duplicate entry" | "Row 23: Email 'jane@example.com' already exists in row 7" |
Allow inline editing
When users can fix errors directly in the validation view, they don't have to download the file, edit it, and re-upload. This saves significant time for files with a few problematic rows.
Filter to problem rows
For large imports, scrolling through thousands of rows to find errors wastes time. The "show only rows with errors" toggle (as seen in ClickUp's import interface) lets users focus on what needs attention.
Common UX mistakes to avoid
Based on research and analysis of common import interfaces, these patterns consistently frustrate users:
1. Generic error messages
"Import failed" tells users nothing. Always specify what went wrong, which row or field caused the problem, and how to fix it.
2. No progress feedback
Users need to know their upload is working. Even a simple spinner is better than nothing, but a progress bar with row count is ideal for large files.
3. All-or-nothing validation
Rejecting an entire 10,000-row file because of one invalid email address is hostile UX. Let users fix individual errors or skip problematic rows.
4. Missing file preview
Users often select the wrong file or export the wrong data. A preview showing the first few rows catches mistakes before they waste time.
5. No undo or rollback
After importing 5,000 records, users may discover they used the wrong file. Without a way to roll back or identify imported records, recovery is painful.
6. Ignoring mobile users
Many import interfaces assume desktop use. If your application has mobile users, ensure the import flow works on small screens.
7. No template provided
Expecting users to guess your column names and formats creates unnecessary friction. A downloadable template removes this guesswork.
Putting it all together
Here's a complete import component that implements the patterns covered in this guide:
"use client";
import { useState } from "react";
import Papa from "papaparse";
type ImportStage = "upload" | "mapping" | "validation" | "complete";
interface ImportState {
stage: ImportStage;
file: File | null;
rawData: Record<string, string>[];
headers: string[];
mappings: Record<string, string>;
validationResults: {
valid: Record<string, string>[];
errors: { row: number; field: string; message: string }[];
};
}
const targetSchema = [
{ key: "email", label: "Email", required: true, type: "email" },
{ key: "firstName", label: "First Name", required: false, type: "string" },
{ key: "lastName", label: "Last Name", required: false, type: "string" },
{ key: "phone", label: "Phone", required: false, type: "string" },
];
export function DataImporter() {
const [state, setState] = useState<ImportState>({
stage: "upload",
file: null,
rawData: [],
headers: [],
mappings: {},
validationResults: { valid: [], errors: [] },
});
const handleFileAccepted = (file: File) => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
const headers = results.meta.fields || [];
const data = results.data as Record<string, string>[];
// Auto-map columns
const mappings: Record<string, string> = {};
headers.forEach(header => {
const match = targetSchema.find(
field => field.label.toLowerCase() === header.toLowerCase() ||
field.key.toLowerCase() === header.toLowerCase()
);
if (match) {
mappings[header] = match.key;
}
});
setState(prev => ({
...prev,
stage: "mapping",
file,
rawData: data,
headers,
mappings,
}));
},
});
};
const handleMappingComplete = () => {
// Validate data
const errors: { row: number; field: string; message: string }[] = [];
const valid: Record<string, string>[] = [];
state.rawData.forEach((row, index) => {
const mappedRow: Record<string, string> = {};
let rowHasError = false;
Object.entries(state.mappings).forEach(([source, target]) => {
const value = row[source] || "";
const field = targetSchema.find(f => f.key === target);
if (field?.required && !value.trim()) {
errors.push({
row: index + 2, // +2 for 1-based index and header row
field: field.label,
message: `${field.label} is required`,
});
rowHasError = true;
}
if (field?.type === "email" && value && !value.includes("@")) {
errors.push({
row: index + 2,
field: field.label,
message: `'${value}' is not a valid email`,
});
rowHasError = true;
}
mappedRow[target] = value;
});
if (!rowHasError) {
valid.push(mappedRow);
}
});
setState(prev => ({
...prev,
stage: "validation",
validationResults: { valid, errors },
}));
};
const handleImport = async () => {
// In a real app, send data to your API
console.log("Importing:", state.validationResults.valid);
setState(prev => ({ ...prev, stage: "complete" }));
};
const reset = () => {
setState({
stage: "upload",
file: null,
rawData: [],
headers: [],
mappings: {},
validationResults: { valid: [], errors: [] },
});
};
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "24px" }}>
{/* Progress indicator */}
<div style={{ display: "flex", marginBottom: "32px" }}>
{["Upload", "Map Columns", "Review", "Complete"].map((step, i) => {
const stages: ImportStage[] = ["upload", "mapping", "validation", "complete"];
const isActive = stages.indexOf(state.stage) >= i;
return (
<div key={step} style={{ flex: 1, textAlign: "center" }}>
<div style={{
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: isActive ? "#3b82f6" : "#e5e7eb",
color: isActive ? "#ffffff" : "#6b7280",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 8px",
fontSize: "14px",
fontWeight: 500,
}}>
{i + 1}
</div>
<div style={{
fontSize: "12px",
color: isActive ? "#374151" : "#9ca3af"
}}>
{step}
</div>
</div>
);
})}
</div>
{/* Stage content */}
{state.stage === "upload" && (
<div>
<h2 style={{ marginBottom: "16px" }}>Upload your CSV file</h2>
<FileUpload onFileAccepted={handleFileAccepted} />
<div style={{ marginTop: "16px", fontSize: "14px", color: "#6b7280" }}>
<a href="/template.csv" download>Download template</a>
</div>
</div>
)}
{state.stage === "mapping" && (
<div>
<h2 style={{ marginBottom: "16px" }}>Map your columns</h2>
<p style={{ color: "#6b7280", marginBottom: "24px" }}>
Match your CSV columns to the fields in our system.
We've auto-matched what we could.
</p>
{/* Column mapper component would go here */}
<div style={{ marginTop: "24px" }}>
<button
onClick={handleMappingComplete}
style={{
padding: "12px 24px",
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
}}
>
Continue to Review
</button>
</div>
</div>
)}
{state.stage === "validation" && (
<div>
<h2 style={{ marginBottom: "16px" }}>Review your data</h2>
<div style={{
padding: "16px",
backgroundColor: "#f0fdf4",
borderRadius: "8px",
marginBottom: "16px"
}}>
<strong style={{ color: "#16a34a" }}>
{state.validationResults.valid.length} rows ready to import
</strong>
</div>
{state.validationResults.errors.length > 0 && (
<div style={{
padding: "16px",
backgroundColor: "#fef2f2",
borderRadius: "8px",
marginBottom: "16px"
}}>
<strong style={{ color: "#dc2626" }}>
{state.validationResults.errors.length} errors found
</strong>
<ul style={{ margin: "8px 0 0", paddingLeft: "20px", fontSize: "14px" }}>
{state.validationResults.errors.slice(0, 5).map((err, i) => (
<li key={i}>Row {err.row}: {err.message}</li>
))}
</ul>
</div>
)}
<div style={{ display: "flex", gap: "12px", marginTop: "24px" }}>
<button
onClick={handleImport}
style={{
padding: "12px 24px",
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
}}
>
Import {state.validationResults.valid.length} rows
</button>
<button
onClick={reset}
style={{
padding: "12px 24px",
backgroundColor: "#ffffff",
color: "#374151",
border: "1px solid #d1d5db",
borderRadius: "6px",
fontSize: "14px",
cursor: "pointer",
}}
>
Start over
</button>
</div>
</div>
)}
{state.stage === "complete" && (
<div style={{ textAlign: "center", padding: "48px 0" }}>
<div style={{
width: "64px",
height: "64px",
backgroundColor: "#dcfce7",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 16px"
}}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<h2 style={{ marginBottom: "8px" }}>Import complete</h2>
<p style={{ color: "#6b7280" }}>
Successfully imported {state.validationResults.valid.length} records.
</p>
<button
onClick={reset}
style={{
marginTop: "24px",
padding: "12px 24px",
backgroundColor: "#3b82f6",
color: "#ffffff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
fontWeight: 500,
cursor: "pointer",
}}
>
Import another file
</button>
</div>
)}
</div>
);
}
// FileUpload component from earlier
function FileUpload({ onFileAccepted }: { onFileAccepted: (file: File) => void }) {
// Implementation from earlier in this guide
return <div>Upload component</div>;
}The easier way: ImportCSV
Building a production-ready data import interface requires handling dozens of edge cases: encoding issues, delimiter detection, large file streaming, accessible UI patterns, and error recovery flows.
ImportCSV provides a pre-built import component that implements all the UX patterns covered in this guide:
- Five-stage flow with progress indicators
- Drag-and-drop with dashed border pattern
- AI-powered column mapping with confidence indicators
- Real-time validation with inline editing
- "Show only errors" filtering
- Accessible by default (keyboard navigation, screen reader support)
import { CSVImporter } from '@importcsv/react';
<CSVImporter
schema={[
{ key: 'email', label: 'Email', type: 'email', required: true },
{ key: 'firstName', label: 'First Name', type: 'string' },
{ key: 'lastName', label: 'Last Name', type: 'string' },
]}
onComplete={(data) => {
// Data is validated and mapped
console.log(data.validRows);
}}
/>Instead of building and maintaining hundreds of lines of import UI code, you can focus on your application's core functionality.
Related posts
Wrap-up
CSV imports shouldn't slow you down. ImportCSV aims to expand into your workflow — whether you're building data import flows, handling customer uploads, or processing large datasets.
If that sounds like the kind of tooling you want to use, try ImportCSV .