All Taste components
Forms
File upload
A drop zone that also opens the file picker, with a row per file showing progress, success or an error.
dropzone · uploading · uploaded · rejected
Business documents
GST-registration-certificate.pdf
shop-front.jpg
pan-card-scan.heic
"use client";
import { useRef, useState } from "react";
import { AlertTriangle, Check, FileText, RotateCw, Upload, X } from "lucide-react";
import { cn } from "@/lib/utils";
type Item = { id: string; file: File; progress: number; done?: boolean; error?: string };
const TYPES = ["application/pdf", "image/jpeg", "image/png"];
const MAX_BYTES = 10 * 1024 * 1024;
const mb = (n: number) => (n / 1024 / 1024).toFixed(1) + " MB";
/** upload(file, onProgress) should resolve when the file is stored. */
export function FileUpload({ label = "Business documents", upload }:
{ label?: string; upload: (file: File, onProgress: (pct: number) => void) => Promise<void> }) {
const [items, setItems] = useState<Item[]>([]);
const [over, setOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const patch = (id: string, p: Partial<Item>) =>
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, ...p } : x)));
function start(item: Item) {
patch(item.id, { progress: 0, error: undefined });
upload(item.file, (progress) => patch(item.id, { progress }))
.then(() => patch(item.id, { progress: 100, done: true }))
.catch(() => patch(item.id, { error: "Upload failed — try again" }));
}
function add(files: FileList | null) {
for (const file of Array.from(files ?? [])) {
const error = !TYPES.includes(file.type) ? "Only PDF, JPG or PNG" : file.size > MAX_BYTES ? "Larger than 10 MB" : undefined;
const item: Item = { id: crypto.randomUUID(), file, progress: 0, error };
setItems((xs) => [...xs, item]);
if (!error) start(item);
}
}
return (
<div className="grid gap-2.5">
<p className="text-[13px] font-medium text-fg">{label}</p>
<label
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); add(e.dataTransfer.files); }}
className={cn(
"grid cursor-pointer justify-items-center gap-0.5 rounded-lg border-[1.5px] border-dashed border-border-strong bg-surface px-4 py-6 text-center transition",
"hover:border-primary focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
over && "border-primary bg-primary-soft",
)}
>
<input ref={inputRef} type="file" multiple accept=".pdf,.jpg,.jpeg,.png" className="sr-only"
onChange={(e) => { add(e.target.files); e.target.value = ""; }} />
<span className="mb-2.5 grid size-10 place-items-center rounded border border-border bg-bg text-fg-muted">
<Upload className="size-[18px]" aria-hidden />
</span>
<span className="text-sm font-medium text-fg">Drop files here or <span className="text-primary underline underline-offset-4">browse</span></span>
<span className="text-xs text-fg-subtle">PDF, JPG or PNG · up to 10 MB each</span>
</label>
{items.map((it) => (
<div key={it.id} className="flex items-center gap-3 rounded border border-border bg-bg px-3 py-2.5">
<span className={cn("grid size-9 shrink-0 place-items-center rounded-sm", it.error ? "bg-danger-soft text-danger-fg" : "bg-surface-2 text-fg-muted")}>
{it.error ? <AlertTriangle className="size-[18px]" aria-hidden /> : <FileText className="size-[18px]" aria-hidden />}
</span>
<div className="grid min-w-0 flex-1 gap-1">
<p className="truncate text-[13.5px] font-medium text-fg">{it.file.name}</p>
{!it.error && !it.done && (
<div role="progressbar" aria-label={"Uploading " + it.file.name} aria-valuenow={it.progress} aria-valuemin={0} aria-valuemax={100}
className="h-1 overflow-hidden rounded-full bg-surface-2">
<span className="block h-full rounded-full bg-primary transition-[width]" style={{ width: it.progress + "%" }} />
</div>
)}
<p className="flex items-center gap-1.5 whitespace-nowrap text-xs tabular-nums text-fg-subtle">
{it.error ? <span className="text-danger">{it.error}</span>
: it.done ? <>{mb(it.file.size)} · <span className="inline-flex items-center gap-1 text-success-fg"><Check className="size-3.5" aria-hidden />Uploaded</span></>
: <>{mb((it.file.size * it.progress) / 100)} of {mb(it.file.size)} · {it.progress}%</>}
</p>
</div>
{it.error && TYPES.includes(it.file.type) && it.file.size <= MAX_BYTES ? (
<button type="button" aria-label={"Try " + it.file.name + " again"} onClick={() => start(it)}
className="grid size-7 place-items-center rounded-sm text-fg-muted hover:bg-surface-2 hover:text-fg"><RotateCw className="size-4" /></button>
) : (
<button type="button" aria-label={"Remove " + it.file.name} onClick={() => setItems((xs) => xs.filter((x) => x.id !== it.id))}
className="grid size-7 place-items-center rounded-sm text-fg-muted hover:bg-surface-2 hover:text-fg"><X className="size-4" /></button>
)}
</div>
))}
</div>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.