Stepper

Import Wizard

Four-step CSV contact import with numbered triggers: a drop zone that reads the file's name and row count in the browser, a column mapping table, a preview table and an import step.

Free, MIT licenseOpen in Browse
"use client"

import { useId, useRef, useState } from "react"
import { CheckIcon, UploadSimpleIcon } from "@phosphor-icons/react"

import {
  Stepper,
  StepperContent,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/stepper/stepper"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Import contacts",
  back: "Back",
  next: "Continue",
  run: (n: number) => `Import ${n.toLocaleString("en")} rows`,
  steps: ["Upload", "Map columns", "Preview", "Import"],
  drop: "Drop a CSV here, or",
  choose: "choose a file",
  rowsWord: "rows",
  imported: (n: number, skipped: number) =>
    `${n.toLocaleString("en")} rows imported, ${skipped} skipped. The skipped rows are listed in your inbox.`,
  file: "contacts-october.csv · 1,240 rows · 84 KB",
  mapHint: "Match each column in the file to a field.",
  previewHint: "The first three rows, as they will import.",
  importing:
    "Ready. Rows with a missing email are skipped and listed afterwards.",
  skipped: (n: number) => `${n} rows will be skipped`,
} as const

const MAPPING = [
  { column: "full_name", field: "Name" },
  { column: "email_address", field: "Email" },
  { column: "org", field: "Company" },
  { column: "signed_up", field: "Created" },
] as const

const ROWS = [
  ["Lena Orvik", "lena@orbcore.dev", "Orbcore", "2026-03-04"],
  ["Tomas Vey", "tomas@orbcore.dev", "Orbcore", "2026-05-18"],
  ["Ines Halloran", "ines@north.studio", "North Studio", "2026-09-01"],
] as const

export default function ImportWizard() {
  const [step, setStep] = useState(0)
  const id = useId()
  const fileInput = useRef<HTMLInputElement>(null)
  // The chosen file's name, size and line count, read in the browser. The
  // column mapping and the import itself are where your API takes over.
  const [file, setFile] = useState<{
    name: string
    rows: number
    kb: number
  } | null>(null)
  const [imported, setImported] = useState(false)
  const rows = file?.rows ?? 1240
  // Only a CSV is read; anything else is ignored. The input resets so the same
  // file can be chosen again after a failed read.
  const read = (f: File | undefined) => {
    if (!f || !/\.csv$/i.test(f.name)) return
    f.text()
      .then((text) => {
        const count = Math.max(
          0,
          text.split(/\r?\n/).filter(Boolean).length - 1
        )
        setFile({ name: f.name, rows: count, kb: Math.round(f.size / 1024) })
      })
      .catch(() => setFile(null))
      .finally(() => {
        if (fileInput.current) fileInput.current.value = ""
      })
  }
  // The demo skips three rows for a missing email, never more than there are.
  const skipped = Math.min(3, rows)
  const last = COPY.steps.length - 1
  return (
    <section className="mx-auto w-full max-w-2xl">
      <h2 className="mb-6 text-xl font-semibold tracking-tight">
        {COPY.title}
      </h2>
      <Stepper value={step} onValueChange={setStep}>
        <StepperList>
          {COPY.steps.map((title, i) => (
            <StepperItem key={title} value={i}>
              <StepperTrigger disabled={imported}>
                <StepperIndicator className="size-7 text-xs">
                  <CheckIcon
                    weight="bold"
                    aria-hidden
                    className="hidden size-3.5 group-data-[state=completed]/step:block"
                  />
                  <span className="group-data-[state=completed]/step:hidden">
                    {i + 1}
                  </span>
                </StepperIndicator>
                <StepperTitle className="sr-only sm:not-sr-only">
                  {title}
                </StepperTitle>
              </StepperTrigger>
              <StepperSeparator className="group-last/step:hidden" />
            </StepperItem>
          ))}
        </StepperList>

        <StepperContent value={0}>
          <div
            className="flex min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border text-sm text-muted-foreground"
            onDragOver={(e) => e.preventDefault()}
            onDrop={(e) => {
              e.preventDefault()
              read(e.dataTransfer.files[0])
            }}
          >
            <UploadSimpleIcon className="size-5" aria-hidden />
            <span>
              {COPY.drop}{" "}
              <button
                type="button"
                className="text-foreground underline underline-offset-4"
                onClick={() => fileInput.current?.click()}
              >
                {COPY.choose}
              </button>
            </span>
            <input
              ref={fileInput}
              id={`${id}file`}
              type="file"
              accept=".csv,text/csv"
              hidden
              onChange={(e) => read(e.target.files?.[0])}
            />
            <span
              className="font-mono text-xs text-foreground"
              aria-live="polite"
            >
              {file
                ? `${file.name} · ${file.rows.toLocaleString("en")} ${COPY.rowsWord} · ${file.kb} KB`
                : COPY.file}
            </span>
          </div>
        </StepperContent>
        <StepperContent value={1}>
          <p className="text-sm text-muted-foreground">{COPY.mapHint}</p>
          <Table>
            <TableBody>
              {MAPPING.map((m) => (
                <TableRow key={m.column}>
                  <TableCell className="font-mono text-xs">
                    {m.column}
                  </TableCell>
                  <TableCell className="text-muted-foreground">→</TableCell>
                  <TableCell>{m.field}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </StepperContent>
        <StepperContent value={2}>
          <p className="text-sm text-muted-foreground">{COPY.previewHint}</p>
          <Table>
            <TableHeader>
              <TableRow>
                {MAPPING.map((m) => (
                  <TableHead key={m.field}>{m.field}</TableHead>
                ))}
              </TableRow>
            </TableHeader>
            <TableBody>
              {ROWS.map((r) => (
                <TableRow key={r[1]}>
                  {r.map((c) => (
                    <TableCell key={c}>{c}</TableCell>
                  ))}
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </StepperContent>
        <StepperContent value={3}>
          <p className="text-sm text-muted-foreground" aria-live="polite">
            {imported ? COPY.imported(rows - skipped, skipped) : COPY.importing}
          </p>
          {!imported && (
            <Badge variant="outline" className="w-fit">
              {COPY.skipped(skipped)}
            </Badge>
          )}
        </StepperContent>

        <div className="flex justify-between">
          <Button
            variant="outline"
            disabled={step === 0 || imported}
            onClick={() => setStep(step - 1)}
          >
            {COPY.back}
          </Button>
          <Button
            disabled={imported}
            onClick={() =>
              step < last ? setStep(step + 1) : setImported(true)
            }
          >
            {step < last ? COPY.next : COPY.run(rows)}
          </Button>
        </div>
      </Stepper>
    </section>
  )
}

Installation

pnpm dlx shadcn add @01orbital/import-wizard
npx shadcn add @01orbital/import-wizard
yarn shadcn add @01orbital/import-wizard
bunx --bun shadcn add @01orbital/import-wizard

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/stepper/import-wizard/index.tsx, as source you own and edit.

Usage

import ImportWizard from "@/components/blocks/stepper/import-wizard"

export default function Example() {
  return <ImportWizard />
}

Dependencies

npm: @phosphor-icons/react. The CLI installs them for you.

Registry pieces it installs with it:

  • badge
  • button
  • font-geist-mono
  • stepper
  • table