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 URLsconst handler = createStack0Handler({endpoint: '/api/upload',})export function UploadExample() {return (<FileUploadhandler={handler}config={{maxSize: 10 * 1024 * 1024, // 10MBmaxFiles: 5,accept: ['image/*', '.pdf'],}}onComplete={(files) => {console.log('All uploads complete:', files)}}/>)}
Props
| Prop | Type | Description |
|---|---|---|
| handler | UploadHandler | Upload handler with getUploadUrl function (required) |
| config | UploadConfig | Configuration for max size, file types, and limits |
| onChange | (files: UploadFile[]) => void | Called when files change (added, removed, status change) |
| onComplete | (files: UploadFile[]) => void | Called when all uploads complete |
| showFileList | boolean | Show the file list below dropzone (default: true) |
| disabled | boolean | Disable the upload component |
| size | "sm" | "default" | "lg" | Dropzone size variant |
Custom Dropzone Content
custom-content.tsx
<FileUploadhandler={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 URLconst 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 backendconst 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><inputtype="file"multipleonChange={(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";// Typestype 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;}// Utilitiesfunction 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)}`;}// Hookfunction 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")),};}// Variantsconst 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" },});// Componentinterface 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)}><divrole="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")}><inputref={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>);}