- Alert
- Autocomplete
- Badge
- CascaderCascader keeps a saved selection's full path, popup open or closed
- Code Block
- Color Picker
- Data GridData Grid works in apps that run React Compiler, with resizing, edits and callbacks kept live
- Date SelectorDate Selector keeps its year list and Today button current in apps that run React Compiler
- Emoji Picker
- Event CalendarEvent Calendar keeps today and the agenda current in apps that run React Compiler
- File TypeFile Type knows 171 formats, .env and Dockerfile included
- File Upload
- Filters
- Frame
- GanttGantt keeps its Today button and tooltip current in apps that run React Compiler
- Icon Stack
- Icon Tile
- Kanban
- Number Field
- Phone Input
- Rating
- Scrollspy
- Signature Pad
- Sortable
- Stepper
- Time Picker
- Timeline
- Tree
Custom Shadcn File Upload for React and Tailwind CSS. Enables users to upload files to a server or application.
"use client"
import { useFileUpload } from "@/hooks/use-file-upload"
import { Button } from "@/components/ui/button"
import { CircleUserRoundIcon } from 'lucide-react'
export function Pattern() {
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
useFileUpload({
accept: "image/*",
})
const previewUrl = files[0]?.preview || null
const fileName = files[0]?.file.name || null
return (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 align-top">
<div
className="border-input rounded-md relative flex size-9 shrink-0 items-center justify-center overflow-hidden border"
aria-label={
previewUrl ? "Preview of uploaded image" : "Default user avatar"
}
>
{previewUrl ? (
<img
className="size-full object-cover"
src={previewUrl}
alt="Preview of uploaded image"
width={32}
height={32}
/>
) : (
<CircleUserRoundIcon className="opacity-60" width="16" height="16" aria-hidden="true" />
)}
</div>
<div className="relative inline-block">
<Button onClick={openFileDialog} aria-haspopup="dialog">
{fileName ? "Change image" : "Upload image"}
</Button>
<input
{...getInputProps()}
className="sr-only"
aria-label="Upload image file"
tabIndex={-1}
/>
</div>
</div>
{fileName ? (
<div className="inline-flex gap-2 text-xs">
<p className="text-muted-foreground truncate" aria-live="polite">
{fileName}
</p>{" "}
<button
onClick={() => removeFile(files[0]?.id)}
className="text-destructive cursor-pointer font-medium hover:underline"
aria-label={`Remove ${fileName}`}
>
Remove
</button>
</div>
) : (
<div className="inline-flex gap-2 text-xs">
<p className="text-muted-foreground truncate" aria-live="polite">
No image attached
</p>
</div>
)}
</div>
)
}
Installation
pnpm dlx shadcn@latest add @reui/use-file-upload
Usage
import { useFileUpload } from "@/hooks/use-file-upload"const [{ files }, { openFileDialog, getInputProps }] = useFileUpload({
accept: "image/*",
multiple: false,
})
return (
<div>
<Button onClick={openFileDialog}>Upload Image</Button>
<input {...getInputProps()} className="sr-only" />
{files.map((file) => (
<div key={file.id}>{file.file.name}</div>
))}
</div>
)For AI Agents
Hand your agent this brief or the ReUI MCP, not a screenshot. Free to install.
When to use
- Profile pictures and cover banners replaced by one pick or drop, with an instant local preview
- Multi file drop zones that check type, size and count, then list picks as thumbnails, cards or table rows
- Upload queues with per file progress and retry, and photo grids put in order by drag before saving
When not to use
- Chips for files sent with a chat message, in the composer or the reply: use the Attachment component
- A colored document icon labeled with its format, such as PDF or XLSX: use the File Type component
- A meeting or booking form where an image drop is one field among many: use the Schedule blocks
Agent notes
- No example sends a picked
Fileto a server; picks stay in React state until you add the upload request
https://web.dev.reui.io/docs/components/radix/file-upload.mdget_component("file-upload")npx shadcn@latest add @reui/use-file-uploadExamples
Avatar Upload
"use client"
import {
formatBytes,
useFileUpload,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { CircleAlertIcon, UserIcon, XIcon } from 'lucide-react'
interface AvatarUploadProps {
maxSize?: number
className?: string
onFileChange?: (file: FileWithPreview | null) => void
defaultAvatar?: string
}
export function Pattern({
maxSize = 2 * 1024 * 1024, // 2MB
className,
onFileChange,
defaultAvatar,
}: AvatarUploadProps) {
const [
{ files, isDragging, errors },
{
removeFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles: 1,
maxSize,
accept: "image/*",
multiple: false,
onFilesChange: (files) => {
onFileChange?.(files[0] || null)
},
})
const currentFile = files[0]
const previewUrl = currentFile?.preview || defaultAvatar
const handleRemove = () => {
if (currentFile) {
removeFile(currentFile.id)
}
}
return (
<div className={cn("flex flex-col items-center gap-4", className)}>
{/* Avatar Preview */}
<div className="relative">
<div
className={cn(
"group/avatar relative h-24 w-24 cursor-pointer overflow-hidden rounded-full border border-dashed transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/20",
previewUrl && "border-solid"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={openFileDialog}
>
<input {...getInputProps()} className="sr-only" />
{previewUrl ? (
<img
src={previewUrl}
alt="Avatar"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<UserIcon className="text-muted-foreground size-6" />
</div>
)}
</div>
{/* Remove Button - only show when file is uploaded */}
{currentFile && (
<Button
size="icon"
variant="outline"
onClick={handleRemove}
className="absolute end-0.5 top-0.5 z-10 size-6 rounded-full dark:bg-zinc-800 hover:dark:bg-zinc-700"
aria-label="Remove avatar"
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
{/* Upload Instructions */}
<div className="space-y-0.5 text-center">
<p className="text-sm font-medium">
{currentFile ? "Avatar uploaded" : "Upload avatar"}
</p>
<p className="text-muted-foreground text-xs">
PNG, JPG up to {formatBytes(maxSize)}
</p>
</div>
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
Compact Upload
"use client"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { CircleAlertIcon, FileIcon, PlusIcon, XIcon } from 'lucide-react'
interface FileUploadCompactProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
}
export function Pattern({
maxFiles = 3,
maxSize = 2 * 1024 * 1024, // 2MB
accept = "image/*",
multiple = true,
className,
onFilesChange,
}: FileUploadCompactProps) {
const [
{ files, isDragging, errors },
{
removeFile,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
onFilesChange,
})
const isImage = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
return type.startsWith("image/")
}
return (
<div className={cn("w-full max-w-lg", className)}>
{/* Compact Upload Area */}
<div
className={cn(
"border-border rounded-lg flex items-center gap-3 border border-dashed p-4 transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
{/* Upload Button */}
<Button
onClick={openFileDialog}
size="sm"
className={cn(isDragging && "animate-bounce")}
>
<PlusIcon className="h-4 w-4" />
Add files
</Button>
{/* File Previews */}
<div className="flex flex-1 items-center gap-2">
{files.length === 0 ? (
<p className="text-muted-foreground text-sm">
Drop files here or click to browse (max {maxFiles} files)
</p>
) : (
files.map((fileItem) => (
<div key={fileItem.id} className="group/item relative shrink-0">
{isImage(fileItem.file) && fileItem.preview ? (
<img
src={fileItem.preview}
alt={fileItem.file.name}
className="h-12 w-12 rounded-lg border object-cover"
title={`${fileItem.file.name} (${formatBytes(fileItem.file.size)})`}
/>
) : (
<div
className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg border"
title={`${fileItem.file.name} (${formatBytes(fileItem.file.size)})`}
>
<FileIcon className="text-muted-foreground h-5 w-5" />
</div>
)}
{/* Remove Button */}
<Button
onClick={() => removeFile(fileItem.id)}
variant="outline"
size="icon"
className="absolute -end-2 -top-2 size-5 rounded-full opacity-0 shadow-md transition-opacity group-hover/item:opacity-100"
>
<XIcon className="size-3" />
</Button>
</div>
))
)}
</div>
{/* File Count */}
{files.length > 0 && (
<div className="text-muted-foreground shrink-0 text-xs">
{files.length}/{maxFiles}
</div>
)}
</div>
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
Gallery Upload
"use client"
import { useState } from "react"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { CircleAlertIcon, ImageIcon, UploadIcon, XIcon, ZoomInIcon } from 'lucide-react'
interface GalleryUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
}
export function Pattern({
maxFiles = 10,
maxSize = 5 * 1024 * 1024, // 5MB
accept = "image/*",
multiple = true,
className,
onFilesChange,
}: GalleryUploadProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(
{}
)
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
// Create default images using FileMetadata type
const defaultImages: FileMetadata[] = [
{
id: "default-1",
name: "avatar-1.png",
size: 44608,
type: "image/png",
url: "https://picsum.photos/1000/800?random=1",
},
{
id: "default-2",
name: "avatar-2.png",
size: 42144,
type: "image/png",
url: "https://picsum.photos/1000/800?random=2",
},
{
id: "default-3",
name: "avatar-2.png",
size: 42144,
type: "image/png",
url: "https://picsum.photos/1000/800?random=3",
},
]
const [
{ files, isDragging, errors },
{
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
initialFiles: defaultImages,
onFilesChange,
})
const isImage = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
return type.startsWith("image/")
}
return (
<div className={cn("w-full max-w-4xl", className)}>
{/* Upload Area */}
<div
className={cn(
"relative rounded-lg border border-dashed p-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded-full",
isDragging ? "bg-primary/10" : "bg-muted"
)}
>
<ImageIcon className="cn(
"h-5 w-5",
isDragging ? "text-primary" : "text-muted-foreground"
)" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Upload images to gallery</h3>
<p className="text-muted-foreground text-sm">
Drag and drop images here or click to browse
</p>
<p className="text-muted-foreground text-xs">
PNG, JPG, GIF up to {formatBytes(maxSize)} each (max {maxFiles}{" "}
files)
</p>
</div>
<Button onClick={openFileDialog}>
<UploadIcon className="h-4 w-4" />
Select images
</Button>
</div>
</div>
{/* Gallery Stats */}
{files.length > 0 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<h4 className="text-sm font-medium">
Gallery ({files.length}/{maxFiles})
</h4>
<div className="text-muted-foreground text-xs">
Total:{" "}
{formatBytes(
files.reduce((acc, file) => acc + file.file.size, 0)
)}
</div>
</div>
<Button onClick={clearFiles} variant="outline" size="sm">
Clear all
</Button>
</div>
)}
{/* Image Grid */}
{files.length > 0 && (
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{files.map((fileItem) => (
<div
key={fileItem.id}
className="group/item relative aspect-square"
>
{isImage(fileItem.file) && fileItem.preview ? (
<>
{loadingImages[fileItem.id] !== false && (
<div className="bg-muted/50 absolute inset-0 flex items-center justify-center rounded-lg border">
<Spinner className="text-muted-foreground size-6" />
</div>
)}
<img
src={fileItem.preview}
alt={fileItem.file.name}
onLoad={() =>
setLoadingImages((prev) => ({
...prev,
[fileItem.id]: false,
}))
}
className={cn(
"h-full w-full rounded-lg border object-cover transition-all group-hover/item:scale-105",
loadingImages[fileItem.id] !== false
? "opacity-0"
: "opacity-100"
)}
/>
</>
) : (
<div className="bg-muted flex h-full w-full items-center justify-center rounded-lg border">
<ImageIcon className="text-muted-foreground h-8 w-8" />
</div>
)}
{/* Overlay */}
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 transition-opacity group-hover/item:opacity-100">
{/* View Button */}
{fileItem.preview && (
<Button
onClick={() => {
setSelectedImage(fileItem.preview!)
setIsPreviewLoading(true)
}}
variant="secondary"
size="icon"
className="size-7"
>
<ZoomInIcon />
</Button>
)}
{/* Remove Button */}
<Button
onClick={() => removeFile(fileItem.id)}
variant="secondary"
size="icon"
className="size-7"
>
<XIcon />
</Button>
</div>
{/* File Info */}
<div className="absolute right-0 bottom-0 left-0 rounded-b-lg bg-black/70 p-2 text-white opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-xs font-medium">
{fileItem.file.name}
</p>
<p className="text-xs text-gray-300">
{formatBytes(fileItem.file.size)}
</p>
</div>
</div>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
{/* Image Preview Dialog */}
<Dialog
open={!!selectedImage}
onOpenChange={(open) => !open && setSelectedImage(null)}
>
<DialogContent className="[&_[data-slot=dialog-close]]:text-muted-foreground [&_[data-slot=dialog-close]]:hover:text-foreground [&_[data-slot=dialog-close]]:bg-background w-full border-none bg-transparent p-0 shadow-none sm:max-w-xl [&_[data-slot=dialog-close]]:-end-7 [&_[data-slot=dialog-close]]:-top-7 [&_[data-slot=dialog-close]]:size-7 [&_[data-slot=dialog-close]]:rounded-full">
<DialogHeader className="sr-only">
<DialogTitle>Image Preview</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center">
{selectedImage && (
<>
{isPreviewLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-8 text-white" />
</div>
)}
<img
src={selectedImage}
alt="Preview"
onLoad={() => setIsPreviewLoading(false)}
className={cn(
"h-full w-auto rounded-lg object-contain transition-opacity duration-300",
isPreviewLoading ? "opacity-0" : "opacity-100"
)}
/>
</>
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}
Progress Upload
"use client"
import { useEffect, useState } from "react"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { CircleAlertIcon, FileArchiveIcon, FileSpreadsheetIcon, FileTextIcon, HeadphonesIcon, ImageIcon, RefreshCwIcon, UploadIcon, VideoIcon, XIcon } from 'lucide-react'
interface FileUploadItem extends FileWithPreview {
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
interface ProgressUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
simulateUpload?: boolean
}
export function Pattern({
maxFiles = 5,
maxSize = 10 * 1024 * 1024, // 10MB
accept = "*",
multiple = true,
className,
onFilesChange,
simulateUpload = true,
}: ProgressUploadProps) {
// Create default images using FileMetadata type
const defaultImages: FileMetadata[] = [
{
id: "default-3",
name: "image-1.png",
size: 42048,
type: "image/png",
url: "https://picsum.photos/1000/800?grayscale&random=10",
},
{
id: "default-4",
name: "image-2.png",
size: 62807,
type: "image/png",
url: "https://picsum.photos/1000/800?grayscale&random=11",
},
]
// Convert default images to FileUploadItem format
const defaultUploadFiles: FileUploadItem[] = defaultImages.map((image) => ({
id: image.id,
file: {
name: image.name,
size: image.size,
type: image.type,
} as File,
preview: image.url,
progress: 100,
status: "completed" as const,
}))
const [uploadFiles, setUploadFiles] =
useState<FileUploadItem[]>(defaultUploadFiles)
const [
{ isDragging, errors },
{
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
initialFiles: defaultImages,
onFilesChange: (newFiles) => {
// Convert to upload items when files change, preserving existing status
const newUploadFiles = newFiles.map((file) => {
// Check if this file already exists in uploadFiles
const existingFile = uploadFiles.find(
(existing) => existing.id === file.id
)
if (existingFile) {
// Preserve existing file status and progress
return {
...existingFile,
...file, // Update any changed properties from the file
}
} else {
// New file - set to uploading
return {
...file,
progress: 0,
status: "uploading" as const,
}
}
})
setUploadFiles(newUploadFiles)
onFilesChange?.(newFiles)
},
})
// Simulate upload progress
useEffect(() => {
if (!simulateUpload) return
const interval = setInterval(() => {
setUploadFiles((prev) =>
prev.map((file) => {
if (file.status !== "uploading") return file
const increment = Math.random() * 15 + 5 // 5-20% increment
const newProgress = Math.min(file.progress + increment, 100)
// Simulate occasional errors (10% chance when progress > 50%)
if (newProgress > 50 && Math.random() < 0.1) {
return {
...file,
status: "error" as const,
error: "Upload failed. Please try again.",
}
}
// Complete when progress reaches 100%
if (newProgress >= 100) {
return {
...file,
progress: 100,
status: "completed" as const,
}
}
return {
...file,
progress: newProgress,
}
})
)
}, 500)
return () => clearInterval(interval)
}, [simulateUpload])
const retryUpload = (fileId: string) => {
setUploadFiles((prev) =>
prev.map((file) =>
file.id === fileId
? {
...file,
progress: 0,
status: "uploading" as const,
error: undefined,
}
: file
)
)
}
const removeUploadFile = (fileId: string) => {
setUploadFiles((prev) => prev.filter((file) => file.id !== fileId))
removeFile(fileId)
}
const getFileIcon = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
if (type.startsWith("image/"))
return (
<ImageIcon className="size-4" />
)
if (type.startsWith("video/"))
return (
<VideoIcon className="size-4" />
)
if (type.startsWith("audio/"))
return (
<HeadphonesIcon className="size-4" />
)
if (type.includes("pdf"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("word") || type.includes("doc"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("excel") || type.includes("sheet"))
return (
<FileSpreadsheetIcon className="size-4" />
)
if (type.includes("zip") || type.includes("rar"))
return (
<FileArchiveIcon className="size-4" />
)
return (
<FileTextIcon className="size-4" />
)
}
const completedCount = uploadFiles.filter(
(f) => f.status === "completed"
).length
const errorCount = uploadFiles.filter((f) => f.status === "error").length
const uploadingCount = uploadFiles.filter(
(f) => f.status === "uploading"
).length
return (
<div className={cn("w-full max-w-2xl", className)}>
{/* Upload Area */}
<div
className={cn(
"rounded-lg relative border border-dashed p-8 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded-full",
isDragging ? "bg-primary/10" : "bg-muted"
)}
>
<UploadIcon className="cn(
"h-6",
isDragging ? "text-primary" : "text-muted-foreground"
)" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Upload your files</h3>
<p className="text-muted-foreground text-sm">
Drag and drop files here or click to browse
</p>
<p className="text-muted-foreground text-xs">
Support for multiple file types up to {formatBytes(maxSize)} each
</p>
</div>
<Button onClick={openFileDialog}>
<UploadIcon className="h-4 w-4" />
Select files
</Button>
</div>
</div>
{/* Upload Stats */}
{uploadFiles.length > 0 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Upload Progress</h4>
<div className="flex items-center gap-2">
{completedCount > 0 && (
<Badge size="sm" variant="success-light">
Completed: {completedCount}
</Badge>
)}
{errorCount > 0 && (
<Badge size="sm" variant="destructive">
Failed: {errorCount}
</Badge>
)}
{uploadingCount > 0 && (
<Badge size="sm" variant="secondary">
Uploading: {uploadingCount}
</Badge>
)}
</div>
</div>
<Button onClick={clearFiles} variant="outline" size="sm">
Clear all
</Button>
</div>
)}
{/* File List */}
{uploadFiles.length > 0 && (
<div className="mt-4 space-y-3">
{uploadFiles.map((fileItem: FileUploadItem) => (
<div
key={fileItem.id}
className="border-border bg-card rounded-lg border p-2.5"
>
<div className="flex items-start gap-2.5">
{/* File Icon */}
<div className="shrink-0">
{fileItem.preview &&
fileItem.file.type.startsWith("image/") ? (
<img
src={fileItem.preview}
alt={fileItem.file.name}
className="rounded-lg h-12 w-12 border object-cover"
/>
) : (
<div className="border-border text-muted-foreground rounded-lg flex h-12 w-12 items-center justify-center border">
{getFileIcon(fileItem.file)}
</div>
)}
</div>
{/* File Info */}
<div className="min-w-0 flex-1">
<div className="mt-0.75 flex items-center justify-between">
<p className="inline-flex flex-col justify-center gap-1 truncate font-medium">
<span className="text-sm">{fileItem.file.name}</span>
<span className="text-muted-foreground text-xs">
{formatBytes(fileItem.file.size)}
</span>
</p>
<div className="flex items-center gap-2">
{/* Remove Button */}
<Button
onClick={() => removeUploadFile(fileItem.id)}
variant="ghost"
size="icon"
className="text-muted-foreground size-6 hover:bg-transparent hover:opacity-100"
>
<XIcon className="size-4" />
</Button>
</div>
</div>
{/* Progress Bar */}
{fileItem.status === "uploading" && (
<div className="mt-2">
<Progress value={fileItem.progress} className="h-1" />
</div>
)}
{/* Error Message */}
{fileItem.status === "error" && fileItem.error && (
<Alert variant="destructive" className="mt-2 px-2 py-1">
<CircleAlertIcon className="size-4" />
<AlertTitle className="text-xs">
{fileItem.error}
</AlertTitle>
<AlertAction>
<Button
onClick={() => retryUpload(fileItem.id)}
variant="ghost"
size="icon"
className="text-muted-foreground size-6 hover:bg-transparent hover:opacity-100"
>
<RefreshCwIcon className="size-3.5" />
</Button>
</AlertAction>
</Alert>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
Table Upload
"use client"
import { useEffect, useState } from "react"
import {
formatBytes,
useFileUpload,
type FileMetadata,
type FileWithPreview,
} from "@/hooks/use-file-upload"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { CircleAlertIcon, CloudUploadIcon, DownloadIcon, FileArchiveIcon, FileSpreadsheetIcon, FileTextIcon, HeadphonesIcon, ImageIcon, RefreshCwIcon, Trash2Icon, UploadIcon, VideoIcon } from 'lucide-react'
interface FileUploadItem extends FileWithPreview {
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
interface TableUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
multiple?: boolean
className?: string
onFilesChange?: (files: FileWithPreview[]) => void
simulateUpload?: boolean
}
export function Pattern({
maxFiles = 10,
maxSize = 50 * 1024 * 1024, // 50MB
accept = "*",
multiple = true,
className,
onFilesChange,
simulateUpload = true,
}: TableUploadProps) {
// Create default files using FileMetadata type
const defaultFiles: FileMetadata[] = [
{
id: "default-doc-1",
name: "document.pdf",
size: 529254,
type: "application/pdf",
url: "/media/files/document.pdf",
},
{
id: "default-doc-2",
name: "intro.zip",
size: 252846,
type: "application/zip",
url: "/media/files/intro.zip",
},
{
id: "default-doc-3",
name: "conclusion.xlsx",
size: 353126,
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
url: "/media/files/conclusion.xlsx",
},
{
id: "default-doc-4",
name: "package.json",
size: 697,
type: "application/json",
url: "/media/files/package.json",
},
]
// Convert default files to FileUploadItem format
const defaultUploadFiles: FileUploadItem[] = defaultFiles.map((file) => ({
id: file.id,
file: {
name: file.name,
size: file.size,
type: file.type,
} as File,
preview: file.url,
progress: 100,
status: "completed" as const,
}))
const [uploadFiles, setUploadFiles] =
useState<FileUploadItem[]>(defaultUploadFiles)
const [
{ isDragging, errors },
{
removeFile,
clearFiles,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
openFileDialog,
getInputProps,
},
] = useFileUpload({
maxFiles,
maxSize,
accept,
multiple,
initialFiles: defaultFiles,
onFilesChange: (newFiles) => {
// Convert to upload items when files change, preserving existing status
const newUploadFiles = newFiles.map((file) => {
// Check if this file already exists in uploadFiles
const existingFile = uploadFiles.find(
(existing) => existing.id === file.id
)
if (existingFile) {
// Preserve existing file status and progress
return {
...existingFile,
...file, // Update any changed properties from the file
}
} else {
// New file - set to uploading
return {
...file,
progress: 0,
status: "uploading" as const,
}
}
})
setUploadFiles(newUploadFiles)
onFilesChange?.(newFiles)
},
})
// Simulate upload progress
useEffect(() => {
if (!simulateUpload) return
const interval = setInterval(() => {
setUploadFiles((prev) =>
prev.map((file) => {
if (file.status !== "uploading") return file
const increment = Math.random() * 15 + 5 // 5-20% increment
const newProgress = Math.min(file.progress + increment, 100)
if (newProgress >= 100) {
// Randomly decide if upload succeeds or fails
const shouldFail = Math.random() < 0.1 // 10% chance to fail
return {
...file,
progress: 100,
status: shouldFail ? ("error" as const) : ("completed" as const),
error: shouldFail
? "Upload failed. Please try again."
: undefined,
}
}
return { ...file, progress: newProgress }
})
)
}, 500)
return () => clearInterval(interval)
}, [simulateUpload])
const removeUploadFile = (fileId: string) => {
setUploadFiles((prev) => prev.filter((file) => file.id !== fileId))
removeFile(fileId)
}
const retryUpload = (fileId: string) => {
setUploadFiles((prev) =>
prev.map((file) =>
file.id === fileId
? {
...file,
progress: 0,
status: "uploading" as const,
error: undefined,
}
: file
)
)
}
const getFileIcon = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
if (type.startsWith("image/"))
return (
<ImageIcon className="size-4" />
)
if (type.startsWith("video/"))
return (
<VideoIcon className="size-4" />
)
if (type.startsWith("audio/"))
return (
<HeadphonesIcon className="size-4" />
)
if (type.includes("pdf"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("word") || type.includes("doc"))
return (
<FileTextIcon className="size-4" />
)
if (type.includes("excel") || type.includes("sheet"))
return (
<FileSpreadsheetIcon className="size-4" />
)
if (type.includes("zip") || type.includes("rar"))
return (
<FileArchiveIcon className="size-4" />
)
return (
<FileTextIcon className="size-4" />
)
}
const getFileTypeLabel = (file: File | FileMetadata) => {
const type = file instanceof File ? file.type : file.type
if (type.startsWith("image/")) return "Image"
if (type.startsWith("video/")) return "Video"
if (type.startsWith("audio/")) return "Audio"
if (type.includes("pdf")) return "PDF"
if (type.includes("word") || type.includes("doc")) return "Word"
if (type.includes("excel") || type.includes("sheet")) return "Excel"
if (type.includes("zip") || type.includes("rar")) return "Archive"
if (type.includes("json")) return "JSON"
if (type.includes("text")) return "Text"
return "File"
}
return (
<div className={cn("w-full space-y-4", className)}>
{/* Upload Area */}
<div
className={cn(
"relative rounded-lg border border-dashed p-6 text-center transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} className="sr-only" />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"bg-muted flex h-12 w-12 items-center justify-center rounded-full transition-colors",
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/25"
)}
>
<UploadIcon className="text-muted-foreground h-5 w-5" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">
Drop files here or{" "}
<button
type="button"
onClick={openFileDialog}
className="text-primary cursor-pointer underline-offset-4 hover:underline"
>
browse files
</button>
</p>
<p className="text-muted-foreground text-xs">
Maximum file size: {formatBytes(maxSize)} • Maximum files:{" "}
{maxFiles}
</p>
</div>
</div>
</div>
{/* Files Table */}
{uploadFiles.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
Files ({uploadFiles.length})
</h3>
<div className="flex gap-2">
<Button onClick={openFileDialog} variant="outline" size="sm">
<CloudUploadIcon className="h-4 w-4" />
Add files
</Button>
<Button onClick={clearFiles} variant="outline" size="sm">
<Trash2Icon className="h-4 w-4" />
Remove all
</Button>
</div>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="text-xs">
<TableHead className="h-9 ps-4">Name</TableHead>
<TableHead className="h-9">Type</TableHead>
<TableHead className="h-9">Size</TableHead>
<TableHead className="h-9 w-[100px] ps-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uploadFiles.map((fileItem) => (
<TableRow key={fileItem.id}>
<TableCell className="py-2 ps-1.5">
<div className="flex items-center gap-1">
<div
className={cn(
"text-muted-foreground/80 relative flex size-8 shrink-0 items-center justify-center"
)}
>
{fileItem.status === "uploading" ? (
<div className="relative">
{/* Circular progress background */}
<svg
className="size-8 -rotate-90"
viewBox="0 0 32 32"
>
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted-foreground/20"
/>
{/* Progress circle */}
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={`${2 * Math.PI * 14}`}
strokeDashoffset={`${2 * Math.PI * 14 * (1 - fileItem.progress / 100)}`}
className="text-primary transition-all duration-300"
strokeLinecap="round"
/>
</svg>
{/* File icon in center */}
<div className="absolute inset-0 flex items-center justify-center">
{getFileIcon(fileItem.file)}
</div>
</div>
) : (
<div className="flex size-8 items-center justify-center">
{getFileIcon(fileItem.file)}
</div>
)}
</div>
<p className="flex items-center gap-1 truncate text-sm font-medium">
{fileItem.file.name}
{fileItem.status === "error" && (
<Badge variant="destructive-light" size="sm">
Error
</Badge>
)}
</p>
</div>
</TableCell>
<TableCell className="py-2">
<Badge variant="secondary" className="text-xs">
{getFileTypeLabel(fileItem.file)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground py-2 text-sm">
{formatBytes(fileItem.file.size)}
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1">
{fileItem.preview && (
<Button
size="icon"
variant="ghost"
className="size-8"
>
<DownloadIcon className="size-3.5" />
</Button>
)}
{fileItem.status === "error" ? (
<Button
onClick={() => retryUpload(fileItem.id)}
variant="ghost"
size="icon"
className="text-destructive/80 hover:text-destructive size-8"
>
<RefreshCwIcon className="size-3.5" />
</Button>
) : (
<Button
onClick={() => removeUploadFile(fileItem.id)}
variant="ghost"
size="icon"
className="size-8"
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
Image Upload
"use client"
import { useCallback, useState } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { CircleAlertIcon, CircleXIcon, CloudUploadIcon, ImageIcon, XIcon } from 'lucide-react'
interface ImageFile {
id: string
file: File
preview: string
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
interface ImageUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
className?: string
onImagesChange?: (images: ImageFile[]) => void
onUploadComplete?: (images: ImageFile[]) => void
}
export function Pattern({
maxFiles = 10,
maxSize = 2 * 1024 * 1024, // 2MB
accept = "image/*",
className,
onImagesChange,
onUploadComplete,
}: ImageUploadProps) {
const [images, setImages] = useState<ImageFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const [visibleDefaultImages, setVisibleDefaultImages] = useState([
{
id: "default-1",
src: "https://picsum.photos/1000/800?grayscale&random=4",
alt: "Product view 1",
},
{
id: "default-2",
src: "https://picsum.photos/1000/800?grayscale&random=5",
alt: "Product view 2",
},
{
id: "default-3",
src: "https://picsum.photos/1000/800?grayscale&random=6",
alt: "Product view 3",
},
{
id: "default-4",
src: "https://picsum.photos/1000/800?grayscale&random=7",
alt: "Product view 4",
},
])
const validateFile = (file: File): string | null => {
if (!file.type.startsWith("image/")) {
return "File must be an image"
}
if (file.size > maxSize) {
return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(1)}MB`
}
if (images.length >= maxFiles) {
return `Maximum ${maxFiles} files allowed`
}
return null
}
const addImages = useCallback(
(files: FileList | File[]) => {
const newImages: ImageFile[] = []
const newErrors: string[] = []
Array.from(files).forEach((file) => {
const error = validateFile(file)
if (error) {
newErrors.push(`${file.name}: ${error}`)
return
}
const imageFile: ImageFile = {
id: `${Date.now()}-${Math.random()}`,
file,
preview: URL.createObjectURL(file),
progress: 0,
status: "uploading",
}
newImages.push(imageFile)
})
if (newErrors.length > 0) {
setErrors((prev) => [...prev, ...newErrors])
}
if (newImages.length > 0) {
const updatedImages = [...images, ...newImages]
setImages(updatedImages)
onImagesChange?.(updatedImages)
// Simulate upload progress
newImages.forEach((imageFile) => {
simulateUpload(imageFile)
})
}
},
[images, maxSize, maxFiles, onImagesChange]
)
const simulateUpload = (imageFile: ImageFile) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 20
if (progress >= 100) {
progress = 100
clearInterval(interval)
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
)
// Check if all uploads are complete
const updatedImages = images.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
if (updatedImages.every((img) => img.status === "completed")) {
onUploadComplete?.(updatedImages)
}
} else {
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id ? { ...img, progress } : img
)
)
}
}, 100)
}
const removeImage = useCallback((id: string) => {
// If it's a default image, remove it from visible defaults
if (id.startsWith("default-")) {
setVisibleDefaultImages((prev) => prev.filter((img) => img.id !== id))
return
}
// Remove uploaded image
setImages((prev) => {
const image = prev.find((img) => img.id === id)
if (image) {
URL.revokeObjectURL(image.preview)
}
return prev.filter((img) => img.id !== id)
})
}, [])
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer.files
if (files.length > 0) {
addImages(files)
}
},
[addImages]
)
const openFileDialog = useCallback(() => {
const input = document.createElement("input")
input.type = "file"
input.multiple = true
input.accept = accept
input.onchange = (e) => {
const target = e.target as HTMLInputElement
if (target.files) {
addImages(target.files)
}
}
input.click()
}, [accept, addImages])
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
}
return (
<div className={cn("w-full max-w-4xl", className)}>
{/* Image Grid - Moved to top */}
<div className="mb-6">
<div className="grid grid-cols-4 gap-2.5">
{/* Always show all visible default images first */}
{visibleDefaultImages.map((defaultImg) => (
<Card
key={defaultImg.id}
className="bg-accent/50 group/item relative flex shrink-0 items-center justify-center rounded-md p-0 shadow-none"
>
<img
src={defaultImg.src}
className="h-[120px] w-full rounded-md object-cover"
alt={defaultImg.alt}
/>
{/* Remove Button Overlay for default images too */}
<Button
onClick={() => removeImage(defaultImg.id)}
variant="outline"
size="icon"
className="absolute end-1 top-1 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<XIcon className="size-3.5" />
</Button>
</Card>
))}
</div>
{/* Show uploaded images in a separate grid below */}
{images.length > 0 && (
<div className="mt-4 grid grid-cols-4 gap-2.5">
{images.map((imageFile, index) => (
<Card
key={imageFile.id}
className="bg-accent/50 group/item relative flex shrink-0 items-center justify-center rounded-md p-0 shadow-none"
>
<img
src={imageFile.preview}
className="h-[120px] w-full rounded-md object-cover"
alt={`Product view ${index + 1}`}
/>
{/* Remove Button Overlay */}
<Button
onClick={() => removeImage(imageFile.id)}
variant="outline"
size="icon"
className="absolute end-2 top-2 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<XIcon className="size-3.5" />
</Button>
</Card>
))}
</div>
)}
</div>
{/* Upload Area */}
<Card
className={cn(
"rounded-md border-dashed shadow-none transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<CardContent className="text-center">
<div className="border-border mx-auto mb-3 flex size-[32px] items-center justify-center rounded-full border">
<CloudUploadIcon className="size-4" />
</div>
<h3 className="text-foreground mb-0.5 text-sm font-semibold">
Choose a file or drag & drop here.
</h3>
<span className="text-secondary-foreground mb-3 block text-xs font-normal">
JPEG, PNG, up to {formatBytes(maxSize)}.
</span>
<Button size="sm" onClick={openFileDialog}>
Browse File
</Button>
</CardContent>
</Card>
{/* Upload Progress Cards */}
{images.length > 0 && (
<div className="mt-6 space-y-3">
{images.map((imageFile) => (
<Card key={imageFile.id} className="rounded-md p-0 shadow-none">
<CardContent className="flex items-center gap-2 p-3">
<div className="border-border flex size-[32px] shrink-0 items-center justify-center rounded-md border">
<ImageIcon className="text-muted-foreground size-4" />
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="-mt-2 flex w-full items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span className="text-foreground text-xs leading-none font-medium">
{imageFile.file.name}
</span>
<span className="text-muted-foreground text-xs leading-none font-normal">
{formatBytes(imageFile.file.size)}
</span>
{imageFile.status === "uploading" && (
<p className="text-muted-foreground text-xs">
Uploading... {Math.round(imageFile.progress)}%
</p>
)}
</div>
<Button
onClick={() => removeImage(imageFile.id)}
variant="ghost"
size="icon"
className="size-6"
>
<CircleXIcon className="size-3.5" />
</Button>
</div>
<Progress
value={imageFile.progress}
className={cn(
"h-1 transition-all duration-300",
"[&>div]:bg-zinc-950 dark:[&>div]:bg-zinc-50"
)}
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
Sortable Upload
"use client"
import { useCallback, useEffect, useState } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import {
Sortable,
SortableItem,
SortableItemHandle,
} from "@/components/reui/sortable"
import { cn } from "cn"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { CircleAlertIcon, CircleXIcon, CloudUploadIcon, GripVerticalIcon, ImageIcon, XIcon } from 'lucide-react'
interface ImageFile {
id: string
file: File
preview: string
progress: number
status: "uploading" | "completed" | "error"
error?: string
}
type SortableImage = {
id: string
src: string
alt: string
type: "default" | "uploaded"
}
interface ImageUploadProps {
maxFiles?: number
maxSize?: number
accept?: string
className?: string
onImagesChange?: (images: ImageFile[]) => void
onUploadComplete?: (images: ImageFile[]) => void
}
export function Pattern({
maxFiles = 5, // Changed to 5 as per UI reference
maxSize = 10 * 1024 * 1024, // 10MB as per UI reference
accept = "image/*",
className,
onImagesChange,
onUploadComplete,
}: ImageUploadProps) {
const [images, setImages] = useState<ImageFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const [allImages, setAllImages] = useState<SortableImage[]>([
{
id: "default-1",
src: "https://picsum.photos/1000/800?grayscale&random=6",
alt: "Product view 1",
type: "default",
},
{
id: "default-2",
src: "https://picsum.photos/1000/800?grayscale&random=7",
alt: "Product view 2",
type: "default",
},
{
id: "default-3",
src: "https://picsum.photos/1000/800?grayscale&random=8",
alt: "Product view 3",
type: "default",
},
{
id: "default-4",
src: "https://picsum.photos/1000/800?grayscale&random=9",
alt: "Product view 4",
type: "default",
},
{
id: "default-5",
src: "https://picsum.photos/1000/800?grayscale&random=10",
alt: "Product view 5",
type: "default",
},
])
// Helper function to create SortableImage from ImageFile
const createSortableImage = useCallback(
(imageFile: ImageFile): SortableImage => ({
id: imageFile.id,
src: imageFile.preview,
alt: imageFile.file.name,
type: "uploaded",
}),
[]
)
// Ensure arrays never contain undefined items
useEffect(() => {
setAllImages((prev) => prev.filter((item) => item && item.id))
setImages((prev) => prev.filter((item) => item && item.id))
}, [])
const validateFile = (file: File): string | null => {
if (!file.type.startsWith("image/")) {
return "File must be an image"
}
if (file.size > maxSize) {
return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(1)}MB`
}
if (images.length >= maxFiles) {
return `Maximum ${maxFiles} files allowed`
}
return null
}
const addImages = useCallback(
(files: FileList | File[]) => {
const newImages: ImageFile[] = []
const newErrors: string[] = []
Array.from(files).forEach((file) => {
const error = validateFile(file)
if (error) {
newErrors.push(`${file.name}: ${error}`)
return
}
const imageFile: ImageFile = {
id: `${Date.now()}-${Math.random()}`,
file,
preview: URL.createObjectURL(file),
progress: 0,
status: "uploading",
}
newImages.push(imageFile)
})
if (newErrors.length > 0) {
setErrors((prev) => [...prev, ...newErrors])
}
if (newImages.length > 0) {
const updatedImages = [...images, ...newImages]
setImages(updatedImages)
onImagesChange?.(updatedImages)
// Add new images to allImages for sorting
const newSortableImages = newImages.map(createSortableImage)
setAllImages((prev) => [...prev, ...newSortableImages])
// Simulate upload progress
newImages.forEach((imageFile) => {
simulateUpload(imageFile)
})
}
},
[images, maxSize, maxFiles, onImagesChange, createSortableImage]
)
const simulateUpload = (imageFile: ImageFile) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 20
if (progress >= 100) {
progress = 100
clearInterval(interval)
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
)
// Check if all uploads are complete
const updatedImages = images.map((img) =>
img.id === imageFile.id
? { ...img, progress: 100, status: "completed" as const }
: img
)
if (updatedImages.every((img) => img.status === "completed")) {
onUploadComplete?.(updatedImages)
}
} else {
setImages((prev) =>
prev.map((img) =>
img.id === imageFile.id ? { ...img, progress } : img
)
)
}
}, 100)
}
const removeImage = useCallback(
(id: string) => {
// Remove from allImages
setAllImages((prev) => prev.filter((img) => img.id !== id))
// If it's an uploaded image, also remove from images array and revoke URL
const uploadedImage = images.find((img) => img.id === id)
if (uploadedImage) {
URL.revokeObjectURL(uploadedImage.preview)
setImages((prev) => prev.filter((img) => img.id !== id))
}
},
[images]
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer.files
if (files.length > 0) {
addImages(files)
}
},
[addImages]
)
const openFileDialog = useCallback(() => {
const input = document.createElement("input")
input.type = "file"
input.multiple = true
input.accept = accept
input.onchange = (e) => {
const target = e.target as HTMLInputElement
if (target.files) {
addImages(target.files)
}
}
input.click()
}, [accept, addImages])
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
}
return (
<div className={cn("w-full max-w-4xl", className)}>
{/* Instructions */}
<div className="mb-4 text-center">
<p className="text-muted-foreground text-sm">
Upload up to {maxFiles} images (JPG, PNG, GIF, WebP, max{" "}
{formatBytes(maxSize)} each). <br />
Drag and drop images to reorder.
{images.length > 0 && ` ${images.length}/${maxFiles} uploaded.`}
</p>
</div>
{/* Image Grid with Sortable */}
<div className="mb-6">
{/* Combined Images Sortable */}
<Sortable
value={allImages.map((item) => item.id)}
onValueChange={(newItemIds) => {
// Reconstruct the allImages array based on the new order
const newAllImages = newItemIds
.map((itemId) => {
// First try to find in allImages (default images)
const existingImage = allImages.find((img) => img.id === itemId)
if (existingImage) return existingImage
// If not found, it's a newly uploaded image
const uploadedImage = images.find((img) => img.id === itemId)
if (uploadedImage) {
return createSortableImage(uploadedImage)
}
return null
})
.filter((item): item is SortableImage => item !== null)
setAllImages(newAllImages)
toast.success("Images reordered successfully!", {
duration: 3000,
})
}}
getItemValue={(item) => item}
strategy="grid"
className="grid auto-rows-fr grid-cols-5 gap-2.5"
>
{allImages.map((item) => (
<SortableItem key={item.id} value={item.id}>
<div className="bg-accent/50 group/item border-border hover:bg-accent/70 relative flex shrink-0 items-center justify-center rounded-md border shadow-none transition-all duration-200 hover:z-10 data-[dragging=true]:z-50">
<img
src={item.src}
className="pointer-events-none h-[120px] w-full rounded-md object-cover"
alt={item.alt}
/>
{/* Drag Handle */}
<SortableItemHandle className="absolute start-2 top-2 cursor-grab opacity-0 group-hover/item:opacity-100 active:cursor-grabbing">
<Button
variant="outline"
size="icon"
className="size-6 rounded-full dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<GripVerticalIcon className="size-3.5" />
</Button>
</SortableItemHandle>
{/* Remove Button Overlay */}
<Button
onClick={() => removeImage(item.id)}
variant="outline"
size="icon"
className="absolute end-2 top-2 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
>
<XIcon className="size-3.5" />
</Button>
</div>
</SortableItem>
))}
</Sortable>
</div>
{/* Upload Area */}
<Card
className={cn(
"rounded-md border-dashed shadow-none transition-colors",
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<CardContent className="text-center">
<div className="border-border mx-auto mb-3 flex size-[32px] items-center justify-center rounded-full border">
<CloudUploadIcon className="size-4" />
</div>
<h3 className="text-foreground mb-0.5 text-sm font-medium">
Choose a file or drag & drop here.
</h3>
<span className="text-secondary-foreground mb-3 block text-xs font-normal">
JPEG, PNG, up to {formatBytes(maxSize)}.
</span>
<Button size="sm" onClick={openFileDialog}>
Browse File
</Button>
</CardContent>
</Card>
{/* Upload Progress Cards */}
{images.length > 0 && (
<div className="mt-6 space-y-3">
{images.map((imageFile) => (
<Card key={imageFile.id} className="rounded-md shadow-none">
<CardContent className="flex items-center gap-2 p-2.5">
<div className="border-border flex size-[32px] shrink-0 items-center justify-center rounded-md border">
<ImageIcon className="text-muted-foreground size-4" />
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="-mt-2 flex w-full items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span className="text-foreground text-xs leading-none font-medium">
{imageFile.file.name}
</span>
<span className="text-muted-foreground text-xs leading-none font-normal">
{formatBytes(imageFile.file.size)}
</span>
{imageFile.status === "uploading" && (
<p className="text-muted-foreground text-xs">
Uploading... {Math.round(imageFile.progress)}%
</p>
)}
</div>
<Button
onClick={() => removeImage(imageFile.id)}
variant="ghost"
size="icon"
className="size-6"
>
<CircleXIcon className="size-3.5" />
</Button>
</div>
<Progress
value={imageFile.progress}
className={cn(
"h-1 transition-all duration-300",
"[&>div]:bg-zinc-950 dark:[&>div]:bg-zinc-50"
)}
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<CircleAlertIcon />
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
)
}
API Reference
useFileUpload
A custom hook for managing file upload state and interactions.
const [state, actions] = useFileUpload(options)Options
| Prop | Type | Default | Description |
|---|---|---|---|
maxFiles | number | Infinity | Maximum number of files allowed (only when multiple is true). |
maxSize | number | Infinity | Maximum size for each file in bytes. |
accept | string | "*" | Accepted file types (e.g., "image/*", ".pdf,.docx"). |
multiple | boolean | false | Whether to allow multiple file selection. |
initialFiles | FileMetadata[] | [] | Initial set of files to populate the state. |
onFilesChange | (files: FileWithPreview[]) => void | - | Callback fired whenever the files list changes. |
onFilesAdded | (files: FileWithPreview[]) => void | - | Callback fired when new valid files are added. |
onError | (errors: string[]) => void | - | Callback fired when validation errors occur. |
State
| Property | Type | Description |
|---|---|---|
files | FileWithPreview[] | The list of currently selected/uploaded files. |
isDragging | boolean | Whether a file is currently being dragged over the target area. |
errors | string[] | Any current validation error messages. |
Actions
| Method | Type | Description |
|---|---|---|
addFiles | (files: FileList | File[]) => void | Manually add files to the state. |
removeFile | (id: string) => void | Remove a file by its unique ID. |
clearFiles | () => void | Remove all files from the state. |
clearErrors | () => void | Clear all current error messages. |
openFileDialog | () => void | Programmatically open the browser's file selection dialog. |
getInputProps | (props?) => InputProps | Returns props for a hidden <input type="file" /> element. |
handleDragEnter | (e) => void | Event handler for the onDragEnter event. |
handleDragLeave | (e) => void | Event handler for the onDragLeave event. |
handleDragOver | (e) => void | Event handler for the onDragOver event. |
handleDrop | (e) => void | Event handler for the onDrop event. |
Types
FileMetadata
type FileMetadata = {
name: string
size: number
type: string
url: string
id: string
}FileWithPreview
type FileWithPreview = {
file: File | FileMetadata
id: string
preview?: string
}formatBytes
A utility function to format a byte count into a human-readable string (e.g., 1.5 MB).
function formatBytes(bytes: number, decimals?: number): stringShadcn File Upload Free Components
Browse 10 production-ready Shadcn File Upload components for dashboards, forms, and product UI. These examples follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 10 Shadcn File Upload components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.