Neomorphic File Upload
A premium, animated file upload component with a neomorphism (soft UI) effect.
Overview
A premium file upload component featuring a "soft UI" neomorphic design. It includes smooth floating animations, drag-and-drop support with visual feedback, and an animated file list with real-time progress simulation.
Features
- Neomorphic Design: Soft shadows and rounded corners for a premium look
- Dynamic Animations: Floating upload icon and smooth list transitions using Framer Motion
- Drag & Drop: Inset shadow feedback when dragging files over the drop zone
- Progress Simulation: Built-in progress bars with status indicators
- Theme Support: Tailored shadows for both Light and Dark modes
- Multi-file Support: Handles multiple files with configurable limits
Preview
Select files
Drag and drop files here or click to browse files
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/neomorphic-file-upload.jsonMethod 2: Manual
-
Install Dependencies
npm install framer-motion lucide-react clsx tailwind-merge -
Copy the Source Code
Copy the code below into
components/zenblocks/neomorphic-file-upload.tsx.
Click to expand source
"use client";
import React, { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Upload, File, X, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface FileWithStatus extends File {
status?: "idle" | "uploading" | "success" | "error";
progress?: number;
}
export const NeomorphicFileUpload = () => {
const [files, setFiles] = useState<FileWithStatus[]>([]);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const newFiles = Array.from(e.target.files).map(file => Object.assign(file, {
status: "idle" as const,
progress: 0
}));
setFiles(prev => [...prev, ...newFiles]);
}
};
const simulateUpload = (index: number) => {
setFiles(prev => {
const next = [...prev];
next[index].status = "uploading";
return next;
});
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
setFiles(prev => {
const next = [...prev];
next[index].status = "success";
next[index].progress = 100;
return next;
});
} else {
setFiles(prev => {
const next = [...prev];
next[index].progress = progress;
return next;
});
}
}, 500);
};
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
return (
<div className="w-full max-w-xl mx-auto p-8 rounded-[3rem] bg-zinc-100 dark:bg-zinc-900 shadow-[20px_20px_60px_#bebebe,-20px_-20px_60px_#ffffff] dark:shadow-[20px_20px_60px_#0a0a0a,-20px_-20px_60px_#1a1a1a] border border-white/20 dark:border-zinc-800/50">
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files) {
const newFiles = Array.from(e.dataTransfer.files).map(file => Object.assign(file, {
status: "idle" as const,
progress: 0
}));
setFiles(prev => [...prev, ...newFiles]);
}
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
"relative group cursor-pointer rounded-3xl p-12 border-2 border-dashed transition-all duration-500",
isDragging
? "border-blue-500 bg-blue-500/5 scale-[0.98]"
: "border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600 bg-zinc-50/50 dark:bg-zinc-800/50"
)}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
className="hidden"
multiple
/>
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className={cn(
"w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-500 shadow-[shadow-[8px_8px_16px_#bebebe,-8px_-8px_16px_#ffffff] dark:shadow-[8px_8px_16px_#0a0a0a,-8px_-8px_16px_#1a1a1a]",
isDragging ? "bg-blue-500 text-white" : "bg-white dark:bg-zinc-900 text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-200"
)}>
<Upload className={cn("w-8 h-8", isDragging && "animate-bounce")} />
</div>
<div>
<p className="text-xl font-black uppercase tracking-tighter text-zinc-900 dark:text-white">
Drop files here
</p>
<p className="mt-1 text-xs font-medium text-zinc-500 uppercase tracking-widest">
Or click to browse
</p>
</div>
</div>
{/* Progress Background */}
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-blue-500/10 backdrop-blur-[2px] rounded-3xl flex items-center justify-center"
>
<span className="font-black text-blue-500 uppercase tracking-[0.2em] animate-pulse">
Drop to Sync
</span>
</motion.div>
)}
</AnimatePresence>
</div>
{/* File List */}
<div className="mt-8 space-y-4">
<AnimatePresence mode="popLayout">
{files.map((file, idx) => (
<motion.div
key={`${file.name}-${idx}`}
layout
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="group relative p-4 rounded-2xl bg-white dark:bg-zinc-800 shadow-sm border border-zinc-200 dark:border-zinc-700/50 flex items-center space-x-4 overflow-hidden"
>
<div className="p-2 rounded-xl bg-zinc-100 dark:bg-zinc-900 text-zinc-500">
<File className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-bold truncate text-zinc-900 dark:text-white uppercase tracking-tight">
{file.name}
</p>
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-zinc-400">
{(file.size / 1024 / 1024).toFixed(2)} MB
</span>
{file.status === "idle" && (
<button
onClick={() => simulateUpload(idx)}
className="p-1 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-blue-500 hover:bg-blue-500 hover:text-white transition-all shadow-sm"
>
<Upload className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Progress Area */}
<div className="mt-2 h-1.5 w-full bg-zinc-100 dark:bg-zinc-900 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${file.progress || 0}%` }}
className={cn(
"h-full transition-colors duration-500",
file.status === "error" ? "bg-red-500" :
file.status === "success" ? "bg-emerald-500" : "bg-blue-500"
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
{file.status === "uploading" && (
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
)}
{file.status === "success" && (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
)}
{file.status === "error" && (
<AlertCircle className="w-4 h-4 text-red-500" />
)}
</div>
<button
onClick={() => removeFile(idx)}
className="p-1.5 rounded-lg text-zinc-400 hover:text-red-500 hover:bg-red-500/10 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Status Mask */}
<AnimatePresence>
{file.status === "success" && (
<motion.div
initial={{ x: "-100%" }}
animate={{ x: "100%" }}
transition={{ duration: 1, ease: "easeInOut" }}
className="absolute inset-0 bg-emerald-500/5 pointer-events-none"
/>
)}
</AnimatePresence>
</motion.div>
))}
</AnimatePresence>
{files.length === 0 && (
<div className="py-4 text-center">
<p className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-400">
No files selected
</p>
</div>
)}
</div>
{/* Footer Controls */}
{files.length > 0 && (
<div className="mt-8 flex items-center justify-between border-t border-zinc-200 dark:border-zinc-800 pt-6">
<button
onClick={() => setFiles([])}
className="text-[10px] font-black uppercase tracking-widest text-zinc-400 hover:text-red-500 transition-colors"
>
Clear All
</button>
<button className="px-6 py-2 rounded-xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-[1.02] active:scale-[0.98] transition-all">
Sync to Cloud
</button>
</div>
)}
</div>
);
};Usage
import NeomorphicFileUpload from "@/components/zenblocks/neomorphic-file-upload";
export default function Example() {
return (
<NeomorphicFileUpload
maxFiles={3}
accept="image/*"
onFilesSelected={(files) => console.log(files)}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
onFilesSelected | (files: File[]) => void | - | Callback when files are added or changed |
maxFiles | number | 5 | Maximum number of files allowed |
accept | string | "*" | Accepted file types |
className | string | - | Additional CSS classes |
Accessibility
- Keyboard Support: Upload zone is clickable and triggers file input
- Visual Feedback: Clear distinct states for dragging and uploading
- Status Indicators: Icons and text clearly communicate success/error/progress
- Reduced Motion: Respects
prefers-reduced-motionimplicitly through Framer Motion
Performance
- Optimized
AnimatePresencefor smooth list management - Minimal re-renders through
useCallbackand state optimization - Lightweight shadows using CSS
box-shadowinstead of heavy SVGs