File Upload
Overview
The FileUpload component provides a modern, accessible interface for uploading files with support for drag-and-drop, file validation, previews, and progress tracking.
Installation
- CLI
- Manual
ignix add component file-upload
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import React, { useCallback, useState, useRef, useMemo } from 'react';
import { Upload, CheckCircle, AlertCircle, Trash2, Loader2, FileText, Sparkles, File, Video, Music, Archive, FileImage } from 'lucide-react';
import { cn } from '../../../utils/cn';
import { Button } from '../button';
import { Avatar } from '../avatar';
import { Typography } from '../typography';
export interface FileUploadProps {
/** Allow multiple file selection */
multiple?: boolean;
/** Maximum number of files allowed */
maxFiles?: number;
/** Maximum file size in bytes */
maxSize?: number;
/** Accepted file types (MIME types or extensions) */
accept?: string;
/** Callback when files are selected */
onFilesChange?: (files: File[]) => void;
/** Display mode: 'button', 'dropzone', or 'both' */
mode?: 'button' | 'dropzone' | 'both';
/** Custom upload button text */
buttonText?: string;
/** Custom dropzone text */
dropzoneText?: string;
/** Show file list */
showFileList?: boolean;
/** Disable the component */
disabled?: boolean;
/** Custom validation function */
validateFile?: (file: File) => { isValid: boolean; error?: string };
/** Custom className */
className?: string;
/** Variant for the upload button */
buttonVariant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost';
/** Show simulated upload progress */
simulateUpload?: boolean;
/** Avatar shape for image files */
imageAvatarShape?: 'circle' | 'square' | 'rounded' | 'hexagon' | 'diamond';
/** Avatar size for image files */
imageAvatarSize?: 'xs' | 'sm' | 'md' | 'lg';
}
interface FileWithPreview {
id: string;
name: string;
type: string;
size: number;
lastModified: number;
preview?: string;
error?: string;
uploading?: boolean;
uploadProgress?: number;
}
export const FileUpload: React.FC<FileUploadProps> = ({
multiple = false,
maxFiles = 10,
maxSize = 10 * 1024 * 1024, // 10MB default
accept = '*/*',
onFilesChange,
mode = 'both',
buttonText = 'Upload Files',
dropzoneText = 'Drag & drop files here or click to browse',
showFileList = true,
disabled = false,
validateFile,
className,
buttonVariant = 'primary',
simulateUpload = false,
imageAvatarShape = 'circle',
imageAvatarSize = 'md',
}) => {
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [dragActive, setDragActive] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Modern color palette with slate for light theme
const colors = {
primary: {
light: 'bg-gradient-to-r from-slate-700 to-slate-800',
dark: 'bg-gradient-to-r from-indigo-600 to-purple-600',
hover: 'hover:from-slate-800 hover:to-slate-900 hover:dark:from-indigo-700 hover:dark:to-purple-700',
border: 'border-slate-300 dark:border-indigo-700',
text: 'text-white',
glow: 'shadow-lg shadow-slate-500/20 dark:shadow-indigo-700/30',
},
success: {
light: 'bg-emerald-500',
dark: 'bg-emerald-600',
text: 'text-emerald-600 dark:text-emerald-400',
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
border: 'border-emerald-200 dark:border-emerald-700',
},
error: {
light: 'bg-rose-500',
dark: 'bg-rose-600',
text: 'text-rose-600 dark:text-rose-400',
bg: 'bg-rose-50 dark:bg-rose-900/20',
border: 'border-rose-200 dark:border-rose-700',
},
warning: {
light: 'bg-amber-500',
dark: 'bg-amber-600',
text: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-50 dark:bg-amber-900/20',
border: 'border-amber-200 dark:border-amber-700',
},
neutral: {
light: 'bg-slate-100',
dark: 'bg-gray-800',
text: 'text-slate-700 dark:text-gray-300',
bg: 'bg-slate-50 dark:bg-gray-900/50',
border: 'border-slate-200 dark:border-gray-700',
hover: 'hover:border-slate-300 dark:hover:border-gray-600',
}
};
// Format file size
const formatFileSize = useCallback((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(2)) + ' ' + sizes[i];
}, []);
// Check if file is an image
const isImageFile = useCallback((file: File | FileWithPreview): boolean => {
if (!file || typeof file.type !== 'string') {
return false;
}
const result = file.type.startsWith('image/');
return result;
}, []);
// Get appropriate file icon based on file type
const getFileIcon = useCallback((file: File | FileWithPreview): React.ReactNode => {
if (!file) {
return <File className="w-6 h-6 text-slate-500" />;
}
const type = file.type || '';
const name = (file.name || '').toLowerCase();
// Handle images
if (type.startsWith('image/')) {
return <FileImage className="w-6 h-6 text-slate-600" />;
}
// Handle PDF
if (type.includes('pdf') || name.endsWith('.pdf')) {
return <FileText className="w-6 h-6 text-red-500" />;
}
// Handle Word documents
if (type.includes('word') || type.includes('document') ||
name.endsWith('.doc') || name.endsWith('.docx')) {
return <FileText className="w-6 h-6 text-blue-500" />;
}
// Handle Excel
if (type.includes('excel') || type.includes('spreadsheet') ||
name.endsWith('.xls') || name.endsWith('.xlsx')) {
return <FileText className="w-6 h-6 text-green-500" />;
}
// Handle video
if (type.includes('video')) {
return <Video className="w-6 h-6 text-orange-500" />;
}
// Handle audio
if (type.includes('audio')) {
return <Music className="w-6 h-6 text-purple-500" />;
}
// Handle archives
if (type.includes('zip') || type.includes('compressed') ||
name.endsWith('.zip') || name.endsWith('.rar') || name.endsWith('.7z')) {
return <Archive className="w-6 h-6 text-slate-500" />;
}
// Handle text files
if (name.endsWith('.txt') || name.endsWith('.rtf') || name.endsWith('.md')) {
return <FileText className="w-6 h-6 text-slate-600" />;
}
return <File className="w-6 h-6 text-slate-500" />;
}, []);
// Get file type display text
const getFileTypeText = useCallback((file: File | FileWithPreview): string => {
if (!file) return 'FILE';
const type = file.type || '';
const name = (file.name || '').toLowerCase();
if (isImageFile(file)) return 'IMAGE';
if (type.includes('pdf') || name.endsWith('.pdf')) return 'PDF';
if (type.includes('word') || type.includes('document') || name.endsWith('.doc') || name.endsWith('.docx')) return 'DOC';
if (type.includes('excel') || type.includes('spreadsheet') || name.endsWith('.xls') || name.endsWith('.xlsx')) return 'EXCEL';
if (type.includes('video')) return 'VIDEO';
if (type.includes('audio')) return 'AUDIO';
if (type.includes('zip') || type.includes('compressed') || name.endsWith('.zip') || name.endsWith('.rar') || name.endsWith('.7z')) return 'ARCHIVE';
if (name.endsWith('.txt') || name.endsWith('.rtf') || name.endsWith('.md')) return 'TEXT';
return 'FILE';
}, [isImageFile]);
// Get icon container background color based on file type
const getIconContainerColor = useCallback((file: File | FileWithPreview): string => {
if (!file) return 'bg-gradient-to-br from-slate-50 to-slate-100 dark:from-gray-800/20 dark:to-gray-900/20';
const type = file.type || '';
const name = (file.name || '').toLowerCase();
if (isImageFile(file)) return 'bg-gradient-to-br from-slate-100 to-slate-200 dark:from-indigo-900/20 dark:to-purple-900/20';
if (type.includes('pdf') || name.endsWith('.pdf')) return 'bg-gradient-to-br from-red-50 to-pink-50 dark:from-red-900/20 dark:to-pink-900/20';
if (type.includes('word') || type.includes('document') || name.endsWith('.doc') || name.endsWith('.docx')) {
return 'bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20';
}
if (type.includes('excel') || type.includes('spreadsheet') || name.endsWith('.xls') || name.endsWith('.xlsx')) {
return 'bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20';
}
if (type.includes('video')) return 'bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20';
if (type.includes('audio')) return 'bg-gradient-to-br from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20';
if (type.includes('zip') || type.includes('compressed') || name.endsWith('.zip') || name.endsWith('.rar') || name.endsWith('.7z')) {
return 'bg-gradient-to-br from-slate-100 to-slate-200 dark:from-gray-800/20 dark:to-gray-700/20';
}
return 'bg-gradient-to-br from-slate-50 to-slate-100 dark:from-gray-800/20 dark:to-gray-900/20';
}, [isImageFile]);
// Create preview for image file
const createImagePreview = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result as string);
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsDataURL(file);
});
};
// Validate file
const validateFileInternal = useCallback((file: File): { isValid: boolean; error?: string } => {
// Check size
if (maxSize && file.size > maxSize) {
return {
isValid: false,
error: `File size exceeds maximum allowed size of ${formatFileSize(maxSize)}`,
};
}
// Check type if accept is specified and not '*/*'
if (accept && accept !== '*/*') {
const acceptedTypes = accept.split(',').map(type => type.trim());
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
const fileType = file.type.toLowerCase();
const isAccepted = acceptedTypes.some(type => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase();
}
if (type.includes('/*')) {
const mainType = type.split('/*')[0];
return fileType.startsWith(mainType);
}
return fileType === type.toLowerCase();
});
if (!isAccepted) {
return {
isValid: false,
error: `File type not allowed. Accepted: ${accept}`,
};
}
}
// Custom validation
if (validateFile) {
return validateFile(file);
}
return { isValid: true };
}, [accept, maxSize, formatFileSize, validateFile]);
// Simulate upload progress
const simulateUploadProgress = useCallback((fileId: string) => {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 20;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
setFiles(prev => prev.map(f =>
f.id === fileId ? {
...f,
uploading: false,
uploadProgress: 100
} : f
));
} else {
setFiles(prev => prev.map(f =>
f.id === fileId ? { ...f, uploading: true, uploadProgress: Math.min(progress, 99) } : f
));
}
}, 200);
return () => clearInterval(interval);
}, []);
// Handle file selection - FIXED VERSION
const handleFiles = useCallback(
async (selectedFiles: FileList | File[]) => {
const fileArray = Array.from(selectedFiles);
const newValidationErrors: string[] = [];
// Check max files
if (!multiple && fileArray.length > 1) {
newValidationErrors.push('Only single file upload is allowed');
}
if (multiple && files.length + fileArray.length > maxFiles) {
newValidationErrors.push(`Maximum ${maxFiles} files allowed`);
}
const validFiles: FileWithPreview[] = [];
// Process each file
for (const file of fileArray) {
const validation = validateFileInternal(file);
if (validation.isValid) {
const fileId = Math.random().toString(36).substring(7);
// Create file with preview if it's an image
let preview: string | undefined;
if (isImageFile(file)) {
try {
preview = await createImagePreview(file);
} catch (error) {
console.error('Failed to create preview:', error);
}
} else {
console.log('Skipping preview for non-image file');
}
// Create file object with explicit properties
const fileWithPreview: FileWithPreview = {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
preview,
uploading: simulateUpload,
uploadProgress: simulateUpload ? 0 : undefined,
};
validFiles.push(fileWithPreview);
} else {
newValidationErrors.push(`${file.name}: ${validation.error}`);
}
}
setValidationErrors(newValidationErrors);
if (validFiles.length > 0) {
const updatedFiles = multiple ? [...files, ...validFiles] : validFiles;
setFiles(updatedFiles);
// Call onFilesChange with plain File objects
const filesForCallback = updatedFiles.map(f => ({
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
}));
onFilesChange?.(filesForCallback);
// Start simulated upload if enabled
if (simulateUpload) {
setIsUploading(true);
validFiles.forEach(file => {
simulateUploadProgress(file.id);
});
setTimeout(() => {
setIsUploading(false);
}, 3000);
}
}
},
[files, multiple, maxFiles, validateFileInternal, onFilesChange, simulateUpload, simulateUploadProgress, isImageFile]
);
// Handle drag events
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
// Handle drop
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
},
[handleFiles]
);
// Handle file input change
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputEvent>) => {
e.preventDefault();
if (e.target.files && e.target.files.length > 0) {
handleFiles(e.target.files);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
},
[handleFiles]
);
// Remove file
const removeFile = useCallback(
(id: string) => {
const updatedFiles = files.filter(file => file.id !== id);
setFiles(updatedFiles);
onFilesChange?.(updatedFiles.map(f => ({
name: f.name,
type: f.type,
size: f.size,
lastModified: f.lastModified,
})));
},
[files, onFilesChange]
);
// Clear all files
const clearAll = useCallback(() => {
setFiles([]);
setValidationErrors([]);
setIsUploading(false);
onFilesChange?.([]);
}, [onFilesChange]);
// Handle button click
const handleButtonClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
// File list component
const fileList = useMemo(() => {
if (!showFileList || files.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Typography variant="h4" weight="semibold" className="bg-gradient-to-r from-slate-800 to-slate-600 dark:from-gray-100 dark:to-gray-300 bg-clip-text text-transparent">
Selected Files
</Typography>
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r from-slate-100 to-slate-200 dark:from-indigo-900/30 dark:to-purple-900/30 text-slate-700 dark:text-indigo-300"
>
{files.length} file{files.length !== 1 ? 's' : ''}
</motion.span>
</div>
{files.length > 0 && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={clearAll}
disabled={isUploading}
className="text-sm font-medium text-slate-500 hover:text-rose-600 dark:text-gray-400 dark:hover:text-rose-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Clear All
</motion.button>
)}
</div>
<div className="grid gap-3">
<AnimatePresence>
{files.map((file, index) => {
const isImage = isImageFile(file);
const showAvatar = isImage && file.preview;
return (
<motion.div
key={file.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: index * 0.05 }}
className={cn(
'group relative p-4 rounded-xl border-2 transition-all duration-300',
'bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm',
'border-slate-100 dark:border-gray-700',
'hover:border-slate-300 dark:hover:border-indigo-600',
'hover:shadow-lg hover:shadow-slate-100/50 dark:hover:shadow-indigo-900/20',
file.error && colors.error.border,
file.uploading && 'border-amber-200 dark:border-amber-600'
)}
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-slate-500/0 via-slate-500/0 to-slate-500/0 group-hover:from-slate-500/5 group-hover:via-slate-500/5 group-hover:to-slate-500/5 transition-all duration-500" />
<div className="relative flex items-center gap-4">
{/* File Icon/Avatar */}
<motion.div
whileHover={{ rotate: 5, scale: 1.1 }}
className="relative"
>
{showAvatar ? (
<>
<div className="absolute -top-2 -right-2 bg-emerald-500 text-white text-xs px-2 py-1 rounded-full z-10">
IMAGE
</div>
<Avatar
size={imageAvatarSize}
shape={imageAvatarShape}
src={file.preview}
alt={file.name}
className={cn(
"ring-2 ring-white dark:ring-gray-800 shadow-md",
file.uploading && "opacity-80"
)}
onError={(e) => {
e.currentTarget.style.border = '2px solid red';
}}
onLoad={() => {
console.log('Avatar loaded successfully for:', file.name);
}}
/>
</>
) : (
<div className={cn(
"w-14 h-14 rounded-xl flex items-center justify-center",
getIconContainerColor(file),
file.uploading && 'opacity-80'
)}>
{getFileIcon(file)}
</div>
)}
{file.uploading && (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
className={cn(
"absolute -inset-1 border-2 border-amber-200 dark:border-amber-600",
showAvatar ? 'rounded-full' : 'rounded-xl'
)}
/>
)}
</motion.div>
{/* File Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Typography
variant="body"
weight="medium"
className="text-slate-800 dark:text-gray-100 truncate"
truncate
>
{file.name}
</Typography>
{file.error ? (
<AlertCircle className="w-4 h-4 text-rose-500 flex-shrink-0" />
) : file.uploading ? (
<Loader2 className="w-4 h-4 text-amber-500 animate-spin flex-shrink-0" />
) : (
<CheckCircle className="w-4 h-4 text-emerald-500 flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-3 mt-1.5">
<Typography
variant="caption"
className="px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-600 dark:text-gray-300"
>
{formatFileSize(file.size)}
</Typography>
<Typography variant="caption" color="muted">
{getFileTypeText(file)}
</Typography>
{isImage && (
<Typography variant="caption" color="muted">
Image
</Typography>
)}
</div>
{/* Upload Progress */}
{file.uploading && (
<div className="mt-3 space-y-1">
<div className="flex justify-between text-xs">
<Typography variant="caption" color="warning" weight="medium">
Uploading
</Typography>
<Typography variant="caption" color="warning">
{Math.round(file.uploadProgress || 0)}%
</Typography>
</div>
<div className="h-2 bg-gradient-to-r from-amber-100 to-amber-200 dark:from-amber-900/30 dark:to-amber-800/30 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${file.uploadProgress || 0}%` }}
transition={{ type: "spring", stiffness: 100 }}
className="h-full bg-gradient-to-r from-amber-400 to-amber-500 rounded-full"
/>
</div>
</div>
)}
{/* Error Message */}
{file.error && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2"
>
<Typography
variant="caption"
color="error"
className="flex items-center gap-1.5"
>
<AlertCircle className="w-4 h-4" />
{file.error}
</Typography>
</motion.div>
)}
</div>
{/* Remove Button */}
<motion.button
whileHover={{ scale: 1.1, rotate: 5 }}
whileTap={{ scale: 0.9 }}
onClick={() => removeFile(file.id)}
disabled={file.uploading}
className={cn(
'p-2 rounded-lg transition-all duration-200',
'text-slate-400 hover:text-rose-600 dark:text-gray-500 dark:hover:text-rose-400',
'hover:bg-rose-50 dark:hover:bg-rose-900/20',
'opacity-0 group-hover:opacity-100 focus:opacity-100',
file.uploading && 'opacity-50 cursor-not-allowed'
)}
aria-label={`Remove ${file.name}`}
>
<Trash2 className="w-5 h-5" />
</motion.button>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
</motion.div>
);
}, [
showFileList,
files,
getFileIcon,
getFileTypeText,
getIconContainerColor,
isImageFile,
formatFileSize,
removeFile,
clearAll,
isUploading,
colors,
imageAvatarSize,
imageAvatarShape
]);
return (
<div className={cn('w-full', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={accept}
onChange={handleChange}
className="hidden"
disabled={disabled || isUploading}
aria-label="File input"
/>
<div className="space-y-6">
{/* Upload Area */}
{(mode === 'dropzone' || mode === 'both') && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={handleButtonClick}
className={cn(
'relative rounded-2xl p-10 transition-all duration-500 cursor-pointer group',
// 'bg-gradient-to-br from-white to-slate-50 dark:from-gray-900 dark:to-gray-800',
'border-3 border-dashed',
dragActive
? 'border-slate-400 dark:border-indigo-500 bg-gradient-to-br from-slate-50/50 to-slate-100/50 dark:from-indigo-900/30 dark:to-purple-900/30'
: 'border-slate-200 dark:border-gray-400',
'hover:border-slate-300 dark:hover:border-indigo-600',
'hover:shadow-2xl hover:shadow-slate-500/10 dark:hover:shadow-indigo-500/20',
(disabled || isUploading) && 'opacity-50 cursor-not-allowed pointer-events-none'
)}
whileHover={!(disabled || isUploading) ? { scale: 1.005 } : {}}
whileTap={!(disabled || isUploading) ? { scale: 0.995 } : {}}
role="button"
tabIndex={(disabled || isUploading) ? -1 : 0}
aria-label="File drop zone"
>
<div className="absolute inset-0 overflow-hidden rounded-2xl">
{dragActive && (
<>
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0],
scale: [0, 1, 0],
x: Math.random() * 400 - 200,
y: Math.random() * 400 - 200,
}}
transition={{
duration: 2,
repeat: Infinity,
delay: i * 0.2,
ease: "easeInOut"
}}
className="absolute w-2 h-2 bg-slate-400 dark:bg-indigo-400 rounded-full"
style={{ left: '50%', top: '50%' }}
/>
))}
</>
)}
</div>
<motion.div
animate={dragActive ? {
y: [0, -15, 0],
rotate: [0, 5, -5, 0]
} : {}}
transition={{
repeat: dragActive ? Infinity : 0,
duration: 1.5,
ease: "easeInOut"
}}
className="flex flex-col items-center justify-center text-center space-y-6"
>
<div className="relative">
<div className={cn(
"w-24 h-24 rounded-2xl flex items-center justify-center border-1",
// dragActive
// ? 'bg-gradient-to-br from-slate-600 to-slate-700 shadow-lg shadow-slate-500/30'
// : 'bg-gradient-to-br from-slate-100 to-slate-200 dark:from-gray-800 dark:to-gray-700'
)}>
{isUploading ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
>
<Loader2 className="w-10 h-10 text-white dark:text-slate-900" />
</motion.div>
) : (
<Upload className={cn(
"w-10 h-10 transition-all duration-300",
dragActive
? 'text-white'
: 'text-slate-400 group-hover:text-slate-600 dark:text-gray-600 dark:group-hover:text-indigo-400'
)} />
)}
</div>
{dragActive && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-2 -right-2"
>
<Sparkles className="w-6 h-6 text-slate-300 dark:text-amber-400" />
</motion.div>
)}
</div>
<div className="space-y-3 max-w-md">
<motion.h3
animate={dragActive ? { scale: 1.05 } : { scale: 1 }}
className="text-2xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 dark:from-gray-100 dark:to-gray-300 bg-clip-text text-transparent"
>
{isUploading ? 'Uploading...' : dropzoneText}
</motion.h3>
<motion.p
initial={{ opacity: 0.8 }}
animate={{ opacity: dragActive ? 1 : 0.8 }}
className="text-slate-500 dark:text-gray-400 text-sm font-medium"
>
{accept !== '*/*' ? `Supports: ${accept}` : 'All file types supported'}
{maxSize && ` • Max size: ${formatFileSize(maxSize)}`}
{multiple && maxFiles > 1 && ` • Max files: ${maxFiles}`}
</motion.p>
{isUploading && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm font-medium text-slate-600 dark:text-indigo-400 flex items-center justify-center gap-2"
>
<Loader2 className="w-4 h-4 animate-spin" />
Processing your files...
</motion.p>
)}
</div>
</motion.div>
</motion.div>
)}
{/* Button Upload */}
{(mode === 'button' || mode === 'both') && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className="flex flex-wrap items-center justify-between gap-4 p-4 rounded-xlr from-white to-slate-50 dark:from-gray-900 dark:to-gray-800 border border-slate-100 dark:border-gray-800 rounded-2xl"
>
<div className="flex flex-wrap items-center gap-4">
<Button
variant={buttonVariant}
onClick={handleButtonClick}
disabled={disabled || isUploading}
className={cn(
"relative overflow-hidden group",
"px-8 py-3 rounded-xl font-semibold",
"bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-800 hover:to-slate-900",
"dark:from-indigo-600 dark:to-purple-600 dark:hover:from-indigo-700 dark:hover:to-purple-700",
"text-white shadow-lg shadow-slate-500/25 hover:shadow-slate-500/40",
"dark:shadow-indigo-500/25 dark:hover:shadow-indigo-500/40",
"transition-all duration-300 transform hover:-translate-y-0.5",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
)}
>
<span className="absolute inset-0 w-full h-full bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000" />
<span className="relative flex items-center gap-3">
{isUploading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<motion.div
whileHover={{ rotate: 180 }}
transition={{ duration: 0.3 }}
>
<Upload className="w-5 h-5" />
</motion.div>
)}
{isUploading ? 'Uploading...' : buttonText}
</span>
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium px-3 py-1.5 rounded-full border border-slate-700 from-slate-100 to-slate-200 dark:from-gray-800 dark:to-gray-700 text-slate-700 dark:text-gray-500">
{files.length} selected
</span>
{isUploading && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="text-sm font-medium px-3 py-1.5 rounded-full bg-gradient-to-br from-amber-100 to-amber-200 dark:from-amber-900/30 dark:to-amber-800/30 text-amber-700 dark:text-amber-300 flex items-center gap-1.5"
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Uploading
</motion.span>
)}
</div>
</div>
{files.length > 0 && (
<Button
variant="ghost"
onClick={clearAll}
disabled={isUploading}
className="text-slate-600 hover:text-rose-600 dark:text-gray-400 dark:hover:text-rose-400 font-medium transition-colors disabled:opacity-50"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
</motion.div>
)}
{/* Validation Errors */}
<AnimatePresence>
{validationErrors.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className={cn(
"rounded-xl p-5",
colors.error.bg,
colors.error.border,
"border"
)}
>
<div className="flex items-start gap-3">
<AlertCircle className="w-6 h-6 text-rose-500 flex-shrink-0 mt-0.5" />
<div className="space-y-2">
<Typography variant="h6" color="error" weight="semibold">
Upload Issues Found
</Typography>
<ul className="space-y-1.5">
{validationErrors.map((error, index) => (
<motion.li
key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="text-sm text-rose-700 dark:text-rose-400 flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" />
{error}
</motion.li>
))}
</ul>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* File List */}
{fileList}
</div>
</div>
);
};
export default FileUpload;
Usage
// Import the component:
import { FileUpload } from '@mindfiredigital/ignix-ui';
Basic File Upload
- Preview
- Code
0 selected
Minimal Button Upload with File Handling
import { FileUpload } from '@mindfiredigital/ignix-ui';
import { useState } from 'react';
function MinimalUploadExample() {
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const handleFilesChange = (newFiles: File[]) => {
setFiles(newFiles);
};
const handleUpload = async () => {
// Example: Create FormData for upload
const formData = new FormData();
files.forEach(file => {
formData.append('files', file);
});
// Send to your backend
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
// Handle response...
};
return (
<div className="flex flex-col items-center justify-center gap-4">
<FileUpload
mode="button"
multiple={true}
buttonText="Choose Files"
buttonVariant="primary"
onFilesChange={handleFilesChange}
className="w-full max-w-md"
/>
</div>
);
}
Drag and Drop File Upload
- Preview
- Code
Drag & drop files here, or click to browse
Supports: image/*, .pdf, .doc, .docx, .txt, .xls, .xlsx • Max size: 10 MB • Max files: 5
0 selected
Complete Usage Example
import { FileUpload } from '@mindfiredigital/ignix-ui';
import { useState } from 'react';
function FileUploadDemo() {
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const handleFilesChange = (newFiles: File[]) => {
setFiles(newFiles);
};
const handleUpload = async () => {
if (files.length === 0) return;
setUploading(true);
try {
// Create FormData to send files to server
const formData = new FormData();
files.forEach(file => {
formData.append('files', file);
});
// Example: Upload to API endpoint
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (response.ok) {
alert('Files uploaded successfully!');
} else {
throw new Error('Upload failed');
}
} catch (error) {
alert('Upload failed: ' + error);
} finally {
setUploading(false);
}
};
return (
<div className="space-y-6">
<FileUpload
onFilesChange={handleFilesChange}
mode="both"
multiple={true}
maxFiles={5}
maxSize={10 * 1024 * 1024}
accept="image/*, .pdf, .doc, .docx, .txt, .xls, .xlsx"
showFileList={true}
buttonText="Upload Files"
dropzoneText="Drag & drop files here, or click to browse"
simulateUpload={true}
/>
</div>
);
}
Custom Validation
function CustomValidationUpload() {
const validateFile = (file: File) => {
// Custom validation rules const errors: string[] = []; // Check file name length
if (file.name.length > 50) {
errors.push('File name must be less than 50 characters');
}
// Check for special characters
if (/[<>:"/\\|?*]/.test(file.name)) {
errors.push('File name contains invalid characters');
}
// Custom size limit based on file type
if (file.type.startsWith('image/') && file.size > 2 * 1024 * 1024) {
errors.push('Images must be smaller than 2MB');
}
return {
isValid: errors.length === 0,
error: errors.join(', ')
};
};
return (
<FileUpload
mode="both"
multiple={true}
buttonText="Upload with Custom Rules"
dropzoneText="Files will be validated against custom rules"
validateFile={validateFile}
accept={""}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'button' | 'dropzone' | 'both' | 'both' | Display mode for the upload interface |
multiple | boolean | false | Allow multiple file selection |
maxFiles | number | 10 | Maximum number of files allowed |
maxSize | number | 10 * 1024 * 1024 | Maximum file size in bytes (10MB default) |
accept | string | '/' | Accepted file types (MIME types or extensions) |
buttonText | string | 'Upload Files' | Custom upload button text |
dropzoneText | string | 'Drag & drop files here or click to browse' | Custom dropzone text |
showFileList | boolean | true | Show file list after selection |
disabled | boolean | false | Disable the component |
buttonVariant | 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'primary' | Variant for the upload button |
simulateUpload | boolean | false | Show simulated upload progress |
validateFile | (file: File) => { isValid: boolean; error?: string } | - | Custom validation function |
onFilesChange | (files: File[]) => void | - | Callback when files are selected |
imageAvatarSize | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Avatar size for image file previews |
imageAvatarShape | 'circle' | 'square' | 'rounded' | 'hexagon' | 'diamond' | 'circle' | Avatar shape for image file previews |
className | string | - | Additional CSS classes |