
Bars variant of Import Wizard: CSV upload with an in-browser row count, column mapping, a preview table and import confirmation, tracked by a segmented rail rather than numbered steps.
blocks/stepper/import-wizard-bars/index.tsx
"use client"
import { useId, useRef, useState } from "react"
import { UploadSimpleIcon } from "@phosphor-icons/react"
import {
Stepper,
StepperContent,
StepperItem,
StepperList,
StepperSeparator,
StepperTitle,
} 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
// The bars twin of import-wizard: same panels and copy, the rail is segments.
export default function ImportWizardBars() {
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} variant="bars">
<StepperList>
{COPY.steps.map((title, i) => (
<StepperItem key={title} value={i}>
<StepperSeparator />
<StepperTitle className="sr-only">{title}</StepperTitle>
</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>
)
}