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

Live Preview

Select files

Drag and drop files here or click to browse files


Installation

Install via the ZenBlocks CLI to automatically handle dependencies:

npx shadcn@latest add https://zenblocks-three.vercel.app/r/neomorphic-file-upload.json

Method 2: Manual

  1. Install Dependencies

    npm install framer-motion lucide-react clsx tailwind-merge
  2. 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

PropTypeDefaultDescription
onFilesSelected(files: File[]) => void-Callback when files are added or changed
maxFilesnumber5Maximum number of files allowed
acceptstring"*"Accepted file types
classNamestring-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-motion implicitly through Framer Motion

Performance

  • Optimized AnimatePresence for smooth list management
  • Minimal re-renders through useCallback and state optimization
  • Lightweight shadows using CSS box-shadow instead of heavy SVGs