Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

Zona para soltar archivos

Arrastra o elige archivos, valida tipo y tamaño y quita de la lista.

  • propuesta-comercial.pdf471 KB
  • logotipo.png90 KB

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

file-dropzone.tsx140 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/zona-subida

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
accept?stringMismo formato que el atributo accept de un input file.
multiple?booleanOpcional.
maxSizeMB?numberOpcional.
onFiles?(files: File[]) => voidSe 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?stringOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

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.