Zona para soltar archivos
Arrastra o elige archivos, valida tipo y tamaño y quita de la lista.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
/**
* Zona para soltar archivos: arrastrar, elegir con el teclado, validar tipo y tamaño y quitar de la lista.
* No sube nada: entrega los archivos válidos en `onFiles` para que los envíes donde quieras.
*
* <FileDropzone accept="image/*,.pdf" maxSizeMB={5} multiple onFiles={(files) => upload(files)} />
*/
import { useId, useState, type DragEvent } from "react";
export type DropzoneFile = { id: string; name: string; size: number };
export type FileDropzoneProps = {
/** Mismo formato que el atributo accept de un input file. */
accept?: string;
multiple?: boolean;
maxSizeMB?: number;
/** Se llama con todos los archivos válidos cada vez que cambia la lista. */
onFiles?: (files: File[]) => void;
/** Lista inicial solo para mostrar (por ejemplo, archivos ya subidos). */
initial?: DropzoneFile[];
className?: string;
};
const CSS = `
.gcd-zone { --gcd-accent: #ff6a3d; --gcd-ink: #1c1d22; --gcd-muted: #6b6f7a; display: block; width: 100%; font-family: inherit; color: var(--gcd-ink); }
.gcd-drop { position: relative; display: grid; justify-items: center; gap: 8px; padding: 34px 24px; border-radius: 18px; background: #fff; text-align: center; cursor: pointer;
background-image: repeating-linear-gradient(90deg, #b7bac4 0 8px, transparent 8px 16px), repeating-linear-gradient(90deg, #b7bac4 0 8px, transparent 8px 16px), repeating-linear-gradient(0deg, #b7bac4 0 8px, transparent 8px 16px), repeating-linear-gradient(0deg, #b7bac4 0 8px, transparent 8px 16px);
background-size: 100% 2px, 100% 2px, 2px 100%, 2px 100%; background-position: 0 0, 0 100%, 0 0, 100% 0; background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
transition: background-color .25s, transform .3s cubic-bezier(.16, 1, .3, 1); }
.gcd-zone[data-over="true"] .gcd-drop { background-color: #fff3ee; transform: scale(1.015); animation: gcd-march .6s linear infinite;
background-image: repeating-linear-gradient(90deg, var(--gcd-accent) 0 8px, transparent 8px 16px), repeating-linear-gradient(90deg, var(--gcd-accent) 0 8px, transparent 8px 16px), repeating-linear-gradient(0deg, var(--gcd-accent) 0 8px, transparent 8px 16px), repeating-linear-gradient(0deg, var(--gcd-accent) 0 8px, transparent 8px 16px); }
.gcd-input { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
.gcd-drop:has(.gcd-input:focus-visible) { outline: 2px solid var(--gcd-accent); outline-offset: 4px; }
.gcd-icon { display: grid; width: 52px; height: 52px; place-items: center; border-radius: 50%; background: #fff0ea; color: var(--gcd-accent); animation: gcd-bob 2.8s ease-in-out infinite; }
.gcd-zone[data-over="true"] .gcd-icon { animation-duration: .8s; }
.gcd-icon svg { width: 24px; height: 24px; }
.gcd-title { font-size: 16px; font-weight: 600; line-height: 1.3; }
.gcd-title u { text-decoration-color: var(--gcd-accent); text-underline-offset: 3px; }
.gcd-hint { color: var(--gcd-muted); font-size: 13px; line-height: 1.4; }
.gcd-error { margin: 10px 0 0; color: #c1301a; font-size: 13px; line-height: 1.4; }
.gcd-list { display: grid; gap: 8px; margin: 14px 0 0; padding: 0; list-style: none; }
.gcd-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid #e3e4e9; border-radius: 12px; background: #fff; animation: gcd-in .35s cubic-bezier(.16, 1, .3, 1); }
.gcd-ext { display: grid; flex: none; width: 36px; height: 36px; place-items: center; border-radius: 9px; background: #1c1d22; color: #fff; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.gcd-meta { flex: 1; min-width: 0; }
.gcd-name { display: block; overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.gcd-size { color: var(--gcd-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.gcd-remove { flex: none; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--gcd-muted); font-size: 18px; line-height: 1; cursor: pointer; }
.gcd-remove:hover { background: #f1f1f4; color: var(--gcd-ink); }
.gcd-remove:focus-visible { outline: 2px solid var(--gcd-accent); }
@keyframes gcd-bob { 50% { transform: translateY(-5px); } }
@keyframes gcd-march { to { background-position: 16px 0, -16px 100%, 0 -16px, 100% 16px; } }
@keyframes gcd-in { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) { .gcd-icon, .gcd-item, .gcd-zone[data-over="true"] .gcd-drop { animation: none; } .gcd-drop { transition: none; } }
`;
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function matchesAccept(file: File, accept?: string) {
if (!accept) return true;
return accept.split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean).some((rule) => {
if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
if (rule.endsWith("/*")) return file.type.toLowerCase().startsWith(rule.slice(0, -1));
return file.type.toLowerCase() === rule;
});
}
export function FileDropzone({ accept, multiple = true, maxSizeMB = 10, onFiles, initial = [], className }: FileDropzoneProps) {
const inputId = useId();
const [items, setItems] = useState<(DropzoneFile & { file?: File })[]>(initial);
const [over, setOver] = useState(false);
const [error, setError] = useState("");
function add(list: FileList | null) {
if (!list) return;
const accepted: (DropzoneFile & { file: File })[] = [];
const problems: string[] = [];
Array.from(list).forEach((file) => {
if (!matchesAccept(file, accept)) problems.push(`${file.name}: tipo no permitido`);
else if (file.size > maxSizeMB * 1024 * 1024) problems.push(`${file.name}: pesa más de ${maxSizeMB} MB`);
else accepted.push({ id: `${file.name}-${file.size}-${file.lastModified}`, name: file.name, size: file.size, file });
});
setError(problems.join(" · "));
const next = multiple ? [...items, ...accepted.filter((entry) => !items.some((item) => item.id === entry.id))] : accepted.slice(0, 1);
setItems(next);
onFiles?.(next.flatMap((entry) => (entry.file ? [entry.file] : [])));
}
function remove(id: string) {
const next = items.filter((item) => item.id !== id);
setItems(next);
onFiles?.(next.flatMap((entry) => (entry.file ? [entry.file] : [])));
}
function onDrop(event: DragEvent<HTMLLabelElement>) {
event.preventDefault();
setOver(false);
add(event.dataTransfer.files);
}
return (
<>
<style>{CSS}</style>
<div className={["gcd-zone", className].filter(Boolean).join(" ")} data-over={over ? "true" : undefined}>
<label
className="gcd-drop"
htmlFor={inputId}
onDragOver={(event) => { event.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={onDrop}
>
<input id={inputId} className="gcd-input" type="file" accept={accept} multiple={multiple} onChange={(event) => { add(event.target.files); event.target.value = ""; }} />
<span className="gcd-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 16V4M7 9l5-5 5 5M5 20h14" /></svg>
</span>
<span className="gcd-title">Suelta tus archivos aquí o <u>elígelos</u></span>
<span className="gcd-hint">{accept ? `${accept.replace(/,/g, ", ")} · ` : ""}hasta {maxSizeMB} MB cada uno</span>
</label>
{error ? <p className="gcd-error" role="alert">{error}</p> : null}
{items.length ? (
<ul className="gcd-list" aria-label="Archivos seleccionados">
{items.map((item) => (
<li key={item.id} className="gcd-item">
<span className="gcd-ext" aria-hidden="true">{item.name.split(".").pop()?.slice(0, 4) ?? "file"}</span>
<span className="gcd-meta"><span className="gcd-name">{item.name}</span><span className="gcd-size">{formatSize(item.size)}</span></span>
<button type="button" className="gcd-remove" onClick={() => remove(item.id)} aria-label={`Quitar ${item.name}`}>×</button>
</li>
))}
</ul>
) : null}
</div>
</>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/zona-subidaEjemplo de uso de Zona para soltar archivos
Después de copiar file-dropzone.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { FileDropzone } from "./file-dropzone";
export default function Ejemplo() {
return <FileDropzone accept="Ejemplo" multiple />;
}Propiedades de FileDropzone
FileDropzone acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
accept? | string | Mismo formato que el atributo accept de un input file. |
multiple? | boolean | Opcional. |
maxSizeMB? | number | Opcional. |
onFiles? | (files: File[]) => void | Se llama con todos los archivos válidos cada vez que cambia la lista. |
initial? | DropzoneFile[] | Lista inicial solo para mostrar (por ejemplo, archivos ya subidos). |
className? | string | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 140 líneas (8 KB) y define 3 animaciones con @keyframes. Esto es lo que hace Zona para soltar archivos por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: alert.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Una zona de carga con borde punteado que se anima al arrastrar encima. Valida el tipo y el peso, muestra la lista con el tamaño de cada archivo y deja quitarlos. No sube nada: te entrega los archivos válidos para que los envíes donde quieras.
Incluye
Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.
- Arrastrar y soltar, o teclado con el input nativo
- Filtro de tipo (accept) y peso máximo
- Mensajes de error accesibles
- Un archivo o varios (multiple)
Cómo usarlo
Pasa accept, maxSizeMB y onFiles. initial sirve para mostrar archivos ya subidos.