# Import 1: CSV import with column matching

> A data import in four steps: choose a CSV, match its columns to your fields (found for you where names are alike), review which rows are ready and which have problems and why, then import with progress and a summary.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/import-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/import-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/import-1-light-c9d8228ae721f0a9.png
- Registry item: https://ui.ballmac.com/r/import-1.json
- Categories: blocks, import
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/import-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/progress.json`, `https://ui.ballmac.com/r/select.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`

## Files

### `registry/ballmac/components/blocks/import-1/import-1.tsx`

```tsx
// Ballmac UI: Import 1. https://ui.ballmac.com/blocks/import-1
"use client"

import * as React from "react"
import { ArrowLeft, ArrowRight, Check, CircleAlert, CircleCheck, FileSpreadsheet, Loader2, UploadCloud } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { Progress } from "@/components/ballmac/progress"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ballmac/select"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Import1Field = {
  key: string
  label: string
  required?: boolean
  /** Return a short reason when a value is not acceptable, or null when it is. Empty values are handled by `required`. */
  validate?: (value: string) => string | null
  /** Other names a column might have, used when matching automatically. */
  aliases?: string[]
}

type Import1Row = Record<string, string>

type Import1Props = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> &
  StateProps & {
    title?: string
    description?: string
    fields?: Import1Field[]
    /** CSV text used by the "Try a sample file" button. */
    sampleCsv?: string
    /** Called with the rows that passed. Return a promise; reject to show a message. Without it, progress is simulated for demos. */
    onImport?: (rows: Import1Row[], progress: (done: number) => void) => void | Promise<void>
    /** Start on this step with the sample file already read (0 to 3). */
    defaultStep?: 0 | 1 | 2 | 3
  }

const defaultFields: Import1Field[] = [
  { key: "name", label: "Name", required: true, aliases: ["full name", "contact", "customer"] },
  { key: "email", label: "Email", required: true, aliases: ["e-mail", "email address", "mail"], validate: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : "Not an email address") },
  { key: "company", label: "Company", aliases: ["organisation", "organization", "employer"] },
  { key: "phone", label: "Phone", aliases: ["mobile", "telephone", "tel"], validate: (v) => (v.replace(/\D/g, "").length >= 7 ? null : "Too short for a phone number") },
  { key: "plan", label: "Plan", aliases: ["tier", "subscription"] },
]

const sample = `Full Name,E-mail,Organisation,Mobile,Tier
Hannah Brooks,hannah@brooks.studio,Brooks Studio,+44 20 7946 0958,Team
Marcus Webb,marcus@webbagency.com,Webb Agency,+1 212 555 0148,Business
Elif Demir,elif@demir.co,Demir & Co,+90 212 555 0177,Team
Tom Alvarez,tom.alvarez@@ops,Alvarez Ops,+34 91 555 0123,Starter
,aisha@rahman.io,Rahman Labs,+44 20 7946 0000,Team
Jonas Keller,jonas@keller.dev,Keller Software,12,Business
Priya Nair,priya@nair.me,,+91 22 5550 0188,Starter
Lucas Moreau,lucas@moreau.fr,Moreau Marketing,+33 1 55 50 01 77,Team
Hannah Brooks,hannah@brooks.studio,Brooks Studio,+44 20 7946 0958,Team
Sofia Rossi,sofia@rossi.it,Rossi Design,+39 02 5550 0144,Business`

// A small CSV reader: quoted fields, doubled quotes, commas and line breaks inside quotes.
function parseCsv(text: string): string[][] {
  const rows: string[][] = []
  let row: string[] = []
  let cell = ""
  let q = false
  for (let i = 0; i < text.length; i++) {
    const c = text[i]!
    if (q) {
      if (c === '"' && text[i + 1] === '"') { cell += '"'; i++ }
      else if (c === '"') q = false
      else cell += c
    } else if (c === '"') q = true
    else if (c === ",") { row.push(cell); cell = "" }
    else if (c === "\n" || c === "\r") {
      if (c === "\r" && text[i + 1] === "\n") i++
      row.push(cell); cell = ""
      if (row.some((x) => x.trim())) rows.push(row)
      row = []
    } else cell += c
  }
  row.push(cell)
  if (row.some((x) => x.trim())) rows.push(row)
  return rows
}

const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
function autoMatch(headers: string[], fields: Import1Field[]) {
  const used = new Set<number>()
  const out: Record<string, number | null> = {}
  for (const f of fields) {
    const names = [f.key, f.label, ...(f.aliases ?? [])].map(norm)
    const i = headers.findIndex((h, idx) => !used.has(idx) && names.includes(norm(h)))
    out[f.key] = i >= 0 ? i : null
    if (i >= 0) used.add(i)
  }
  return out
}

const stepNames = ["Upload", "Match columns", "Review", "Import"]

function Skel() {
  return (
    <div aria-hidden="true" className="bg-card rounded-3xl border p-8">
      <Skeleton className="h-6 w-60" />
      <Skeleton className="mt-8 h-48 rounded-2xl" />
    </div>
  )
}

/**
 * A data import in four steps: choose a CSV file, match its columns to your fields (found for you where the names are alike), review which rows
 * are ready and which have problems and why, then import with progress and a summary. Missing required values, bad emails or phones and duplicate
 * rows are all caught before anything is sent, and a failure keeps you on the review with the rows intact.
 */
function Import1({
  title = "Import contacts",
  description = "Bring in a spreadsheet saved as CSV. We will check it before anything is added.",
  fields = defaultFields,
  sampleCsv = sample,
  onImport,
  defaultStep = 0,
  state = "ready",
  onRetry,
  errorState,
  className,
  ...props
}: Import1Props) {
  const id = React.useId()
  const [step, setStep] = React.useState<number>(defaultStep)
  const [fileName, setFileName] = React.useState(defaultStep ? "contacts.csv" : "")
  const [table, setTable] = React.useState<string[][]>(() => (defaultStep ? parseCsv(sampleCsv) : []))
  const [map, setMap] = React.useState<Record<string, number | null>>(() => (defaultStep ? autoMatch(parseCsv(sampleCsv)[0] ?? [], fields) : {}))
  const [onlyProblems, setOnlyProblems] = React.useState(false)
  const [drag, setDrag] = React.useState(false)
  const [readError, setReadError] = React.useState<string>()
  const [done, setDone] = React.useState(0)
  const [phase, setPhase] = React.useState<"idle" | "running" | "done" | "error">("idle")
  const [message, setMessage] = React.useState<string>()
  const headingRef = React.useRef<HTMLHeadingElement>(null)
  const first = React.useRef(true)
  const input = React.useRef<HTMLInputElement>(null)

  React.useEffect(() => {
    if (first.current) first.current = false
    else headingRef.current?.focus()
  }, [step])

  const headers = table[0] ?? []
  const body = table.slice(1)
  const missing = fields.filter((f) => f.required && map[f.key] == null)

  const rows = React.useMemo(() => {
    const seen = new Map<string, number>()
    const emailKey = fields.find((f) => f.key === "email")?.key
    return body.map((r, i) => {
      const values: Import1Row = {}
      const issues: string[] = []
      for (const f of fields) {
        const c = map[f.key]
        const v = c == null ? "" : (r[c] ?? "").trim()
        values[f.key] = v
        if (!v && f.required) issues.push(`${f.label} is missing`)
        else if (v && f.validate) {
          const bad = f.validate(v)
          if (bad) issues.push(`${f.label}: ${bad}`)
        }
      }
      if (emailKey && values[emailKey]) {
        const k = values[emailKey]!.toLowerCase()
        if (seen.has(k)) issues.push(`Same email as row ${seen.get(k)! + 2}`)
        else seen.set(k, i)
      }
      return { n: i + 2, values, issues }
    })
  }, [body, fields, map])
  const good = rows.filter((r) => !r.issues.length)
  const bad = rows.length - good.length

  function read(text: string, name: string) {
    const t = parseCsv(text)
    if (t.length < 2) return setReadError("That file has no rows under the header. Check it is a CSV with a first row of column names.")
    setReadError(undefined)
    setTable(t)
    setFileName(name)
    setMap(autoMatch(t[0]!, fields))
    setStep(1)
  }
  async function file(f?: File | null) {
    if (!f) return
    if (!/\.csv$|text\/csv|text\/plain/i.test(f.name + f.type)) return setReadError("Choose a CSV file. In your spreadsheet, use Save as and pick CSV.")
    read(typeof f.text === "function" ? await f.text() : await new Promise<string>((res) => { const r = new FileReader(); r.onload = () => res(String(r.result ?? "")); r.readAsText(f) }), f.name)
  }

  async function run() {
    setPhase("running")
    setDone(0)
    setMessage(undefined)
    try {
      if (onImport) await onImport(good.map((r) => r.values), (n) => setDone(n))
      else {
        for (let i = 1; i <= 10; i++) {
          await new Promise((r) => setTimeout(r, 140))
          setDone(Math.round((good.length * i) / 10))
        }
      }
      setDone(good.length)
      setPhase("done")
    } catch (e) {
      setMessage(e instanceof Error ? e.message : undefined)
      setPhase("error")
    }
  }

  const reset = () => {
    setStep(0)
    setTable([])
    setFileName("")
    setPhase("idle")
    setDone(0)
  }

  const stepper = (
    <ol aria-label="Steps" className="mb-8 flex items-center gap-2">
      {stepNames.map((n, i) => (
        <li key={n} aria-current={i === step ? "step" : undefined} className="flex flex-1 items-center gap-2 last:flex-none">
          <span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums", i < step && "bg-foreground text-background border-foreground", i === step && "border-foreground")}>
            {i < step ? <Check aria-hidden="true" className="size-4" /> : i + 1}
          </span>
          <span className={cn("hidden text-sm sm:block", i === step ? "font-semibold" : "text-muted-foreground")}>
            {n}
            <span className="sr-only">{i < step ? ", done" : i === step ? ", current step" : ""}</span>
          </span>
          {i < stepNames.length - 1 ? <span aria-hidden="true" className={cn("h-px flex-1", i < step ? "bg-foreground" : "bg-border")} /> : null}
        </li>
      ))}
    </ol>
  )

  const head = (text: string, sub?: string) => (
    <>
      <h2 ref={headingRef} tabIndex={-1} className="text-2xl font-semibold tracking-tight outline-none">
        {text}
      </h2>
      {sub ? <p className="text-muted-foreground mt-1.5 text-pretty">{sub}</p> : null}
    </>
  )

  const content = (
    <div className="bg-card rounded-3xl border p-5 sm:p-8">
      {stepper}

      {step === 0 ? (
        <div>
          {head("Choose a file", "A CSV with a first row of column names. Up to 10,000 rows.")}
          <div
            onDragOver={(e) => { e.preventDefault(); setDrag(true) }}
            onDragLeave={() => setDrag(false)}
            onDrop={(e) => { e.preventDefault(); setDrag(false); void file(e.dataTransfer.files[0]) }}
            className={cn("mt-6 flex flex-col items-center rounded-2xl border-2 border-dashed px-6 py-14 text-center transition-colors motion-reduce:transition-none", drag ? "border-foreground bg-accent/50" : "hover:bg-accent/30")}
          >
            <span aria-hidden="true" className="bg-muted flex size-14 items-center justify-center rounded-full">
              <UploadCloud className="size-6" />
            </span>
            <p className="mt-4 font-medium">Drag a CSV file here</p>
            <p className="text-muted-foreground mt-1 text-sm">or</p>
            <div className="mt-3 flex flex-wrap justify-center gap-2.5">
              <Button onClick={() => input.current?.click()}>Choose a file</Button>
              <Button variant="outline" onClick={() => read(sampleCsv, "contacts.csv")}>
                <FileSpreadsheet aria-hidden="true" />
                Try a sample file
              </Button>
            </div>
            <input ref={input} type="file" accept=".csv,text/csv" className="sr-only" aria-label="CSV file" tabIndex={-1} onChange={(e) => void file(e.target.files?.[0])} />
          </div>
          {readError ? (
            <p role="alert" className="text-destructive mt-4 flex items-start gap-2 text-sm">
              <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
              {readError}
            </p>
          ) : null}
        </div>
      ) : null}

      {step === 1 ? (
        <div>
          {head("Match your columns", `${fileName} has ${body.length} rows. We matched what we could; check each one.`)}
          <ul className="mt-6 divide-y rounded-2xl border">
            {fields.map((f) => {
              const c = map[f.key]
              const sampleVals = c == null ? [] : body.slice(0, 3).map((r) => r[c]).filter(Boolean)
              return (
                <li key={f.key} className="grid items-center gap-3 p-4 sm:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1.2fr)]">
                  <p id={`${id}-${f.key}`} className="text-sm font-medium">
                    {f.label}
                    {f.required ? <span className="text-destructive" aria-hidden="true"> *</span> : null}
                    {f.required ? <span className="sr-only"> (required)</span> : null}
                  </p>
                  <Select value={c == null ? "none" : String(c)} onValueChange={(v) => setMap((m) => ({ ...m, [f.key]: v === "none" ? null : Number(v) }))}>
                    <SelectTrigger aria-labelledby={`${id}-${f.key}`} className="w-full">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="none">Do not import</SelectItem>
                      {headers.map((h, i) => (
                        <SelectItem key={i} value={String(i)}>
                          {h || `Column ${i + 1}`}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                  <p className="text-muted-foreground truncate text-sm">{sampleVals.length ? sampleVals.join(" · ") : <span>{f.required ? "Choose a column to continue" : "Not imported"}</span>}</p>
                </li>
              )
            })}
          </ul>
          {missing.length ? (
            <p role="alert" className="text-destructive mt-4 text-sm">
              Match {missing.map((f) => f.label).join(" and ")} to continue.
            </p>
          ) : null}
        </div>
      ) : null}

      {step === 2 ? (
        <div>
          {head("Review before importing")}
          <div className="mt-5 grid gap-3 sm:grid-cols-3">
            <div className="rounded-2xl border p-4">
              <p className="text-3xl font-semibold tracking-tight tabular-nums">{good.length}</p>
              <p className="text-muted-foreground text-sm">ready to import</p>
            </div>
            <div className={cn("rounded-2xl border p-4", bad && "border-destructive/40 bg-destructive/5")}>
              <p className="text-3xl font-semibold tracking-tight tabular-nums">{bad}</p>
              <p className="text-muted-foreground text-sm">with problems, skipped</p>
            </div>
            <div className="rounded-2xl border p-4">
              <p className="text-3xl font-semibold tracking-tight tabular-nums">{rows.length}</p>
              <p className="text-muted-foreground text-sm">rows in the file</p>
            </div>
          </div>
          {bad ? (
            <label className="mt-5 flex w-fit cursor-pointer items-center gap-2 text-sm">
              <input type="checkbox" checked={onlyProblems} onChange={(e) => setOnlyProblems(e.target.checked)} className="accent-foreground size-4" />
              Show only the rows with problems
            </label>
          ) : null}
          <div role="region" tabIndex={0} aria-label="Rows from your file" className="focus-visible:ring-ring/50 mt-4 max-h-96 overflow-auto rounded-2xl border outline-none focus-visible:ring-[3px]">
            <table className="w-full min-w-[44rem] text-sm">
              <caption className="sr-only">Rows from your file and whether each can be imported</caption>
              <thead className="bg-muted/60 sticky top-0 text-start">
                <tr>
                  <th scope="col" className="px-4 py-2.5 text-start font-medium">Row</th>
                  <th scope="col" className="min-w-52 px-4 py-2.5 text-start font-medium">Result</th>
                  {fields.filter((f) => map[f.key] != null).map((f) => (
                    <th key={f.key} scope="col" className="px-4 py-2.5 text-start font-medium">
                      {f.label}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody className="divide-y">
                {rows.filter((r) => !onlyProblems || r.issues.length).map((r) => (
                  <tr key={r.n} className={cn(r.issues.length && "bg-destructive/5")}>
                    <td className="text-muted-foreground px-4 py-2.5 tabular-nums">{r.n}</td>
                    <td className="px-4 py-2.5 align-top">
                      {r.issues.length ? (
                        <span className="inline-flex items-start gap-1.5">
                          <CircleAlert aria-hidden="true" className="text-destructive mt-0.5 size-4 shrink-0" />
                          <span>{r.issues.join(". ")}</span>
                        </span>
                      ) : (
                        <span className="inline-flex items-center gap-1.5">
                          <CircleCheck aria-hidden="true" className="text-chart-2 size-4" />
                          Ready
                        </span>
                      )}
                    </td>
                    {fields.filter((f) => map[f.key] != null).map((f) => (
                      <td key={f.key} className="max-w-44 truncate px-4 py-2.5 align-top">
                        {r.values[f.key] || <span className="text-muted-foreground">Empty</span>}
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          {!good.length ? (
            <p role="alert" className="text-destructive mt-4 text-sm">
              No row can be imported. Go back and check the column matches.
            </p>
          ) : null}
        </div>
      ) : null}

      {step === 3 ? (
        <div className="py-4 text-center" aria-busy={phase === "running"}>
          {phase === "done" ? (
            <div role="status">
              <span aria-hidden="true" className="bg-chart-2/15 mx-auto flex size-14 items-center justify-center rounded-full">
                <CircleCheck className="size-7" />
              </span>
              <h2 ref={headingRef} tabIndex={-1} className="mt-5 text-2xl font-semibold tracking-tight outline-none">
                {good.length} {good.length === 1 ? "contact" : "contacts"} imported
              </h2>
              <p className="text-muted-foreground mx-auto mt-2 max-w-sm text-pretty">{bad ? `${bad} ${bad === 1 ? "row was" : "rows were"} skipped because of problems.` : "Every row came in."}</p>
              <Button variant="outline" className="mt-6" onClick={reset}>
                Import another file
              </Button>
            </div>
          ) : (
            <div>
              <h2 ref={headingRef} tabIndex={-1} className="text-2xl font-semibold tracking-tight outline-none">
                {phase === "running" ? "Importing…" : phase === "error" ? "The import stopped" : `Import ${good.length} ${good.length === 1 ? "row" : "rows"}`}
              </h2>
              <p className="text-muted-foreground mx-auto mt-2 max-w-sm text-pretty">{phase === "running" ? "Keep this page open until it finishes." : phase === "error" ? (errorState?.description ?? message ?? "Nothing was lost. Your rows are still here, so try again.") : bad ? `${bad} ${bad === 1 ? "row" : "rows"} with problems will be skipped.` : "Every row is ready."}</p>
              {phase === "running" || phase === "error" ? (
                <div className="mx-auto mt-6 max-w-md">
                  <Progress value={good.length ? (done / good.length) * 100 : 0} aria-label="Import progress" />
                  <p className="text-muted-foreground mt-2 text-sm tabular-nums">
                    {done} of {good.length}
                  </p>
                </div>
              ) : null}
              {phase === "error" ? (
                <p role="alert" className="text-destructive mt-4 text-sm">
                  {message ?? "We could not finish the import."}
                </p>
              ) : null}
            </div>
          )}
        </div>
      ) : null}

      {step > 0 && !(step === 3 && phase === "done") ? (
        <div className="mt-8 flex items-center justify-between gap-3 border-t pt-5">
          <Button variant="ghost" onClick={() => setStep(step - 1)} disabled={phase === "running"}>
            <ArrowLeft aria-hidden="true" className="rtl:rotate-180" />
            Back
          </Button>
          {step < 3 ? (
            <Button onClick={() => setStep(step + 1)} disabled={(step === 1 && missing.length > 0) || (step === 2 && !good.length)}>
              {step === 2 ? `Continue with ${good.length} ${good.length === 1 ? "row" : "rows"}` : "Continue"}
              <ArrowRight aria-hidden="true" className="rtl:rotate-180" />
            </Button>
          ) : (
            <Button onClick={run} disabled={phase === "running" || !good.length}>
              {phase === "running" ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
              {phase === "running" ? "Importing" : phase === "error" ? "Try again" : "Start import"}
            </Button>
          )}
        </div>
      ) : null}
    </div>
  )

  return (
    <section data-slot="import-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-4xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
      <header className="mb-8">
        <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
          {title}
        </h1>
        <p className="text-muted-foreground mt-2 text-pretty">{description}</p>
      </header>
      <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the importer" loading={<Skel />} error={{ title: errorState?.title ?? "The importer could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}>
        {content}
      </StateView>
    </section>
  )
}

export { Import1, type Import1Props, type Import1Field, type Import1Row }
```
