Animated File Upload
Animated File Upload é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
forms
O que é
Animated File Upload é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i motion
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Animated File Upload
"use client";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useRef, useState } from "react";
export interface AnimatedFileUploadProps {
accept?: string;
className?: string;
disabled?: boolean;
maxSize?: number;
multiple?: boolean;
onFilesSelected: (files: File[]) => void;
}
/* ─────────────────────────────────────────────────────────
* ANIMATION STORYBOARD
*
* 0ms drop zone visible, dashed border idle
* drag zone scales 1.02, border goes primary, icon floats up
* drop icon bounces back, file rows slide in staggered
* remove file row slides out right + fades
* layout remaining files reorder with layout animation
* ───────────────────────────────────────────────────────── */
const SPRING = {
bounce: 0.1,
duration: 0.25,
type: "spring" as const,
};
const SPRING_BOUNCY = {
bounce: 0.2,
duration: 0.3,
type: "spring" as const,
};
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon() {
return (
<svg
aria-hidden="true"
className="h-4 w-4 text-muted-foreground"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function UploadIcon({ isDragOver }: { isDragOver: boolean }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
animate={
shouldReduceMotion
? undefined
: isDragOver
? { scale: 1.15, y: -4 }
: { scale: 1, y: 0 }
}
transition={shouldReduceMotion ? { duration: 0 } : SPRING_BOUNCY}
>
<svg
aria-hidden="true"
className={cn(
"mb-3 h-10 w-10 transition-colors duration-200",
isDragOver ? "text-foreground" : "text-muted-foreground"
)}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.div>
);
}
export default function AnimatedFileUpload({
onFilesSelected,
accept,
multiple = true,
maxSize,
className,
disabled = false,
}: AnimatedFileUploadProps) {
const shouldReduceMotion = useReducedMotion();
const inputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [error, setError] = useState<string | null>(null);
const dragCounter = useRef(0);
const validateFiles = useCallback(
(newFiles: File[]): File[] => {
setError(null);
if (maxSize) {
const oversized = newFiles.filter((f) => f.size > maxSize);
if (oversized.length > 0) {
setError(
`${oversized.length} file(s) exceed the ${formatFileSize(maxSize)} limit`
);
return newFiles.filter((f) => f.size <= maxSize);
}
}
return newFiles;
},
[maxSize]
);
const handleFiles = useCallback(
(newFiles: File[]) => {
const valid = validateFiles(newFiles);
if (valid.length === 0) {
return;
}
const updated = multiple ? [...files, ...valid] : valid.slice(0, 1);
setFiles(updated);
onFilesSelected(updated);
},
[files, multiple, onFilesSelected, validateFiles]
);
const removeFile = useCallback(
(index: number) => {
const updated = files.filter((_, i) => i !== index);
setFiles(updated);
onFilesSelected(updated);
},
[files, onFilesSelected]
);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (disabled) {
return;
}
dragCounter.current += 1;
setIsDragOver(true);
},
[disabled]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounter.current -= 1;
if (dragCounter.current === 0) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (disabled) {
}
},
[disabled]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
dragCounter.current = 0;
setIsDragOver(false);
if (disabled) {
return;
}
const droppedFiles = Array.from(e.dataTransfer.files);
handleFiles(droppedFiles);
},
[disabled, handleFiles]
);
const handleClick = () => {
if (!disabled) {
inputRef.current?.click();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
e.preventDefault();
inputRef.current?.click();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files ? Array.from(e.target.files) : [];
handleFiles(selected);
if (inputRef.current) {
inputRef.current.value = "";
}
};
return (
<div className={cn("w-full space-y-3", className)}>
<motion.div
animate={
shouldReduceMotion
? undefined
: isDragOver
? { scale: 1.02 }
: { scale: 1 }
}
aria-label="File upload area. Drag and drop files or press to browse"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-10",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"transition-colors duration-200",
isDragOver
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/40 hover:bg-muted/30",
disabled && "pointer-events-none opacity-50"
)}
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onKeyDown={handleKeyDown}
role="button"
tabIndex={disabled ? -1 : 0}
transition={shouldReduceMotion ? { duration: 0 } : SPRING}
>
<input
accept={accept}
className="sr-only"
disabled={disabled}
multiple={multiple}
onChange={handleInputChange}
ref={inputRef}
type="file"
/>
<UploadIcon isDragOver={isDragOver} />
<AnimatePresence initial={false} mode="wait">
<motion.p
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
className="font-medium text-foreground text-sm"
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
key={isDragOver ? "drop" : "idle"}
transition={
shouldReduceMotion ? { duration: 0 } : { duration: 0.15 }
}
>
{isDragOver ? "Drop files here" : "Drag & drop or click to upload"}
</motion.p>
</AnimatePresence>
<p className="mt-1 text-muted-foreground text-xs">
{accept ? accept.replace(/,/g, ", ") : "Any file type"}
{maxSize ? ` \u2022 Max ${formatFileSize(maxSize)}` : null}
</p>
</motion.div>
<AnimatePresence>
{error ? (
<motion.p
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
className="text-destructive text-sm"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, y: -4 }
}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
role="alert"
transition={shouldReduceMotion ? { duration: 0 } : SPRING}
>
{error}
</motion.p>
) : null}
</AnimatePresence>
{files.length > 0 && (
<ul aria-label="Selected files" className="space-y-2">
<AnimatePresence initial={false}>
{files.map((file, index) => (
<motion.li
animate={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, scale: 1, x: 0 }
}
className="flex items-center gap-3 rounded-md border bg-muted/50 px-3 py-2"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scale: 0.95,
transition: { duration: 0.15 },
x: 24,
}
}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.95, x: -16 }
}
key={`${file.name}-${file.size}-${file.lastModified}`}
layout={!shouldReduceMotion}
transition={shouldReduceMotion ? { duration: 0 } : SPRING}
>
<FileIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm">
{file.name}
</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(file.size)}
</p>
</div>
<motion.button
aria-label={`Remove ${file.name}`}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
type="button"
whileHover={shouldReduceMotion ? undefined : { scale: 1.1 }}
whileTap={shouldReduceMotion ? undefined : { scale: 0.9 }}
>
<svg
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
</div>
);
}