File Upload

A drag-and-drop file upload component with progress tracking, multi-file support, and retry functionality.

Demo

Click to upload or drag and drop

image/*, .pdf, .txt

Max 10 MB

Basic Usage

upload-example.tsx
import { FileUpload, createStack0Handler } from '@stack0/elements'
// Handler calls your API endpoint which returns presigned URLs
const handler = createStack0Handler({
endpoint: '/api/upload',
})
export function UploadExample() {
return (
<FileUpload
handler={handler}
config={{
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
accept: ['image/*', '.pdf'],
}}
onComplete={(files) => {
console.log('All uploads complete:', files)
}}
/>
)
}

Props

PropTypeDescription
handlerUploadHandlerUpload handler with getUploadUrl function (required)
configUploadConfigConfiguration for max size, file types, and limits
onChange(files: UploadFile[]) => voidCalled when files change (added, removed, status change)
onComplete(files: UploadFile[]) => voidCalled when all uploads complete
showFileListbooleanShow the file list below dropzone (default: true)
disabledbooleanDisable the upload component
size"sm" | "default" | "lg"Dropzone size variant

Custom Dropzone Content

custom-content.tsx
<FileUpload
handler={handler}
config={{ accept: ['image/*'], maxFiles: 1 }}
>
<div className="flex flex-col items-center gap-2">
<CloudIcon className="h-12 w-12 text-blue-400" />
<p className="text-lg font-medium">Drop your image here</p>
<p className="text-sm text-muted-foreground">
PNG, JPG, or GIF up to 10MB
</p>
</div>
</FileUpload>

With Custom Handler

custom-handler.tsx
import { FileUpload, type UploadHandler } from '@stack0/elements'
const myHandler: UploadHandler = {
getUploadUrl: async (file) => {
// Call your API to get a presigned URL
const res = await fetch('/api/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
})
const { uploadUrl, key } = await res.json()
return { uploadUrl, assetId: key }
},
onUploadComplete: async (assetId) => {
// Confirm the upload with your backend
const res = await fetch(`/api/upload/${assetId}/confirm`, {
method: 'POST',
})
const { url } = await res.json()
return { url }
},
}
<FileUpload handler={myHandler} />

Using the Hook Directly

For more control, use the useUpload hook directly:

use-upload-hook.tsx
import { useUpload, createStack0Handler } from '@stack0/elements'
const handler = createStack0Handler({ endpoint: '/api/upload' })
function CustomUploader() {
const {
files,
isUploading,
addFiles,
removeFile,
retryFile,
clearFiles,
clearCompleted,
} = useUpload({
handler,
maxSize: 10 * 1024 * 1024,
maxFiles: 5,
})
return (
<div>
<input
type="file"
multiple
onChange={(e) => addFiles(Array.from(e.target.files || []))}
/>
{files.map((file) => (
<div key={file.id}>
{file.file.name} - {file.status} ({file.progress}%)
{file.status === 'error' && (
<button onClick={() => retryFile(file.id)}>Retry</button>
)}
<button onClick={() => removeFile(file.id)}>Remove</button>
</div>
))}
</div>
)
}

Full Component Source

Copy this into your project at components/ui/file-upload.tsx:

components/ui/file-upload.tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { CheckCircle, File, RefreshCw, Upload, X, XCircle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
// Types
type UploadStatus = "idle" | "pending" | "uploading" | "processing" | "complete" | "error";
interface UploadFile {
id: string;
file: File;
progress: number;
status: UploadStatus;
error?: string;
url?: string;
assetId?: string;
}
interface UploadHandler {
getUploadUrl: (file: File) => Promise<{ uploadUrl: string; assetId?: string }>;
onUploadComplete?: (assetId: string, file: File) => Promise<{ url?: string }>;
onError?: (error: Error, file: File) => void;
}
interface UploadConfig {
maxSize?: number;
accept?: string[];
maxFiles?: number;
}
// Utilities
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
// Hook
function useUpload(options: UploadConfig & { handler: UploadHandler }) {
const { handler, maxSize = 10 * 1024 * 1024, maxFiles = 10, accept } = options;
const [files, setFiles] = React.useState<UploadFile[]>([]);
const updateFile = React.useCallback(
(id: string, updates: Partial<UploadFile>) => {
setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...updates } : f)));
},
[]
);
const uploadFile = React.useCallback(
async (uploadFile: UploadFile) => {
const { file, id } = uploadFile;
try {
if (file.size > maxSize) {
updateFile(id, {
status: "error",
error: `File too large. Max ${Math.round(maxSize / 1024 / 1024)}MB`,
});
return;
}
updateFile(id, { status: "uploading" });
const { uploadUrl, assetId } = await handler.getUploadUrl(file);
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
updateFile(id, { progress: Math.round((e.loaded / e.total) * 100) });
}
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject());
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
xhr.send(file);
});
updateFile(id, { status: "processing", progress: 100, assetId });
let finalUrl: string | undefined;
if (handler.onUploadComplete && assetId) {
const result = await handler.onUploadComplete(assetId, file);
finalUrl = result.url;
}
updateFile(id, { status: "complete", url: finalUrl });
} catch (error) {
const msg = error instanceof Error ? error.message : "Upload failed";
updateFile(id, { status: "error", error: msg });
handler.onError?.(error instanceof Error ? error : new Error(msg), file);
}
},
[handler, maxSize, updateFile]
);
const addFiles = React.useCallback(
(newFiles: File[]) => {
let filesToAdd = newFiles;
if (accept?.length) {
filesToAdd = newFiles.filter((file) =>
accept.some((type) =>
type.startsWith(".") ? file.name.toLowerCase().endsWith(type.toLowerCase())
: type.endsWith("/*") ? file.type.startsWith(type.slice(0, -1))
: file.type === type
)
);
}
filesToAdd = filesToAdd.slice(0, maxFiles - files.length);
if (!filesToAdd.length) return;
const uploadFiles: UploadFile[] = filesToAdd.map((file) => ({
id: generateId(),
file,
progress: 0,
status: "pending",
}));
setFiles((prev) => [...prev, ...uploadFiles]);
uploadFiles.forEach(uploadFile);
},
[accept, files.length, maxFiles, uploadFile]
);
return {
files,
isUploading: files.some((f) => f.status === "uploading" || f.status === "processing"),
addFiles,
removeFile: (id: string) => setFiles((prev) => prev.filter((f) => f.id !== id)),
retryFile: (id: string) => {
const file = files.find((f) => f.id === id);
if (file?.status === "error") {
const newFile = { ...file, progress: 0, status: "pending" as const, error: undefined };
setFiles((prev) => prev.map((f) => (f.id === id ? newFile : f)));
uploadFile(newFile);
}
},
clearFiles: () => setFiles([]),
clearCompleted: () => setFiles((prev) => prev.filter((f) => f.status !== "complete")),
};
}
// Variants
const dropzoneVariants = cva(
"relative rounded-lg border-2 border-dashed transition-colors cursor-pointer",
{
variants: {
variant: {
default: "border-muted-foreground/25 hover:border-muted-foreground/50",
active: "border-primary bg-primary/5",
},
size: { sm: "p-4", default: "p-6", lg: "p-8" },
},
defaultVariants: { variant: "default", size: "default" },
}
);
// Component
interface FileUploadProps extends VariantProps<typeof dropzoneVariants> {
handler: UploadHandler;
config?: UploadConfig;
onChange?: (files: UploadFile[]) => void;
onComplete?: (files: UploadFile[]) => void;
showFileList?: boolean;
children?: React.ReactNode;
disabled?: boolean;
className?: string;
}
export function FileUpload({
handler,
config,
onChange,
onComplete,
showFileList = true,
children,
disabled = false,
className,
size,
}: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const { files, isUploading, addFiles, removeFile, retryFile, clearCompleted } = useUpload({
handler,
...config,
});
const prevFilesRef = React.useRef<UploadFile[]>([]);
React.useEffect(() => {
if (JSON.stringify(files) !== JSON.stringify(prevFilesRef.current)) {
prevFilesRef.current = files;
onChange?.(files);
const allComplete = files.length > 0 && files.every((f) => f.status === "complete" || f.status === "error");
if (allComplete && !isUploading) onComplete?.(files);
}
}, [files, isUploading, onChange, onComplete]);
return (
<div className={cn("space-y-4", className)}>
<div
role="button"
tabIndex={disabled ? -1 : 0}
onDragOver={(e) => { e.preventDefault(); !disabled && setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); !disabled && addFiles(Array.from(e.dataTransfer.files)); }}
onClick={() => !disabled && inputRef.current?.click()}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && inputRef.current?.click()}
className={cn(
dropzoneVariants({ variant: isDragging ? "active" : "default", size }),
disabled && "opacity-50 cursor-not-allowed",
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
)}
>
<input
ref={inputRef}
type="file"
multiple={config?.maxFiles !== 1}
accept={config?.accept?.join(",")}
onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }}
className="hidden"
disabled={disabled}
/>
{children || (
<div className="flex flex-col items-center justify-center text-center">
<Upload className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground mb-1">
<span className="font-medium text-foreground">Click to upload</span> or drag and drop
</p>
{config?.accept && <p className="text-xs text-muted-foreground">{config.accept.join(", ")}</p>}
{config?.maxSize && <p className="text-xs text-muted-foreground">Max {formatBytes(config.maxSize)}</p>}
</div>
)}
</div>
{showFileList && files.length > 0 && (
<div className="space-y-2">
{files.map((f) => (
<div key={f.id} className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<div className="h-10 w-10 rounded bg-muted flex items-center justify-center">
{f.status === "complete" ? <CheckCircle className="h-5 w-5 text-green-500" />
: f.status === "error" ? <XCircle className="h-5 w-5 text-destructive" />
: <File className="h-5 w-5 text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{f.file.name}</span>
<span className="text-xs text-muted-foreground">{formatBytes(f.file.size)}</span>
</div>
{f.status === "uploading" && (
<div className="mt-2 h-1 w-full bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all" style={{ width: `${f.progress}%` }} />
</div>
)}
{f.status === "processing" && <p className="text-xs text-muted-foreground mt-1">Processing...</p>}
{f.status === "error" && <p className="text-xs text-destructive mt-1">{f.error}</p>}
</div>
<div className="flex items-center gap-1">
{f.status === "error" && (
<button onClick={() => retryFile(f.id)} className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-muted">
<RefreshCw className="h-4 w-4" />
</button>
)}
{["pending", "error", "complete"].includes(f.status) && (
<button onClick={() => removeFile(f.id)} className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-muted">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
{files.some((f) => f.status === "complete") && (
<button onClick={clearCompleted} className="text-xs text-muted-foreground hover:text-foreground">
Clear completed
</button>
)}
</div>
)}
</div>
);
}