Lista de subidas con progreso
Porcentaje por archivo, cancelar, reintentar tras un error y completado con check. Simulación visual, sin red.
- propuesta-comercial.pdf
- fotos-sucursal.zip
- base-clientes.csv
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
/** Lista de subidas con progreso por archivo: porcentaje, velocidad simulada, cancelar, reintentar tras un error y completado con check. Material oscuro. Simulación visual, sin red. React + TypeScript, sin dependencias. */
export type ProgresoSubidaArchivo = { id: string; name: string; size: string; fail?: boolean };
export type ProgresoSubidaProps = {
files?: ProgresoSubidaArchivo[];
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcprs-root { box-sizing: border-box; width: 100%; max-width: 460px; padding: 8px; border: 1px solid #2a2d36; border-radius: 16px; background: #14161b; color: #e9ebf2; font: 500 14px/1.35 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcprs-root *, .gcprs-root *::before, .gcprs-root *::after { box-sizing: border-box; }
.gcprs-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcprs-root :focus-visible { outline: 2px solid #8fb4ff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcprs-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcprs-list { margin: 0; padding: 0; list-style: none; }
.gcprs-item { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px; border-radius: 10px; }
.gcprs-item + .gcprs-item { border-top: 1px solid #22252d; border-radius: 0; }
.gcprs-ico { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: #1e222b; color: #8fb4ff; }
.gcprs-item[data-s="error"] .gcprs-ico { color: #ff7b7b; background: #2a1c1f; }
.gcprs-item[data-s="done"] .gcprs-ico { color: #7be3a1; background: #17261e; }
.gcprs-name { display: flex; justify-content: space-between; gap: 10px; font-weight: 600; }
.gcprs-name span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gcprs-meta { font-size: 12px; color: #8b92a5; font-variant-numeric: tabular-nums; }
.gcprs-track { height: 4px; margin: 8px 0 6px; overflow: hidden; border-radius: 2px; background: #252932; }
.gcprs-fill { height: 100%; border-radius: 2px; background: #8fb4ff; transition: width .25s linear, background .2s; }
.gcprs-item[data-s="error"] .gcprs-fill { background: #ff7b7b; }
.gcprs-item[data-s="done"] .gcprs-fill { background: #7be3a1; }
.gcprs-act { min-height: 34px; padding: 0 12px; border: 1px solid #2a2d36; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; font-size: 13px; }
.gcprs-act:hover { background: #1e222b; }
.gcprs-empty { padding: 22px; text-align: center; color: #8b92a5; }
.gcprs-foot { display: flex; justify-content: flex-end; padding: 8px 12px 4px; }`;
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function useReduced() {
return useSyncExternalStore(
(notify) => {
const query = window.matchMedia(REDUCED_QUERY);
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
},
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
const DEFAULT: ProgresoSubidaArchivo[] = [
{ id: "1", name: "propuesta-comercial.pdf", size: "2.4 MB" },
{ id: "2", name: "fotos-sucursal.zip", size: "48 MB", fail: true },
{ id: "3", name: "base-clientes.csv", size: "860 KB" },
];
type St = { p: number; s: "up" | "done" | "error" | "cancel" };
export function ProgresoSubida({ files = DEFAULT, className = "", style }: ProgresoSubidaProps) {
const reduced = useReduced();
const [run, setRun] = useState(0);
const fresh = () => Object.fromEntries(files.map((file) => [file.id, { p: 0, s: "up" as const }])) as Record<string, St>;
const [state, setState] = useState<Record<string, St>>(fresh);
const tries = useRef<Record<string, number>>({});
useEffect(() => {
if (reduced) return;
const id = window.setInterval(() => {
if (document.hidden) return;
setState((prev) => {
const next = { ...prev };
files.forEach((file, i) => {
const cur = prev[file.id];
if (!cur || cur.s !== "up") return;
const speed = 2 + ((i * 3) % 5) + Math.random() * 4;
const p = Math.min(100, cur.p + speed);
if (file.fail && !tries.current[file.id] && p > 62) next[file.id] = { p: 62, s: "error" };
else next[file.id] = { p, s: p >= 100 ? "done" : "up" };
});
return next;
});
}, 280);
return () => window.clearInterval(id);
}, [files, run, reduced]);
const label = { up: "Subiendo", done: "Completado", error: "Error de red", cancel: "Cancelado" };
const retry = (id: string) => { tries.current[id] = 1; setState((prev) => ({ ...prev, [id]: { p: prev[id].p, s: "up" } })); };
const cancel = (id: string) => setState((prev) => ({ ...prev, [id]: { p: prev[id].p, s: "cancel" } }));
const visible = files.filter((file) => state[file.id]?.s !== "cancel");
return (
<div className={`gcprs-root ${className}`} style={style}>
<style>{CSS}</style>
{visible.length === 0 ? <p className="gcprs-empty">No hay subidas activas.</p> : null}
<ul className="gcprs-list">
{visible.map((file) => {
const st = reduced ? { p: 100, s: "done" as const } : state[file.id] ?? { p: 0, s: "up" as const };
return (
<li key={file.id} className="gcprs-item" data-s={st.s}>
<span className="gcprs-ico" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">{st.s === "done" ? <path d="m5 12.5 4.5 4.5L19 7.5" /> : st.s === "error" ? <path d="M12 8v5M12 16.5v.5M10.3 4.2 2.8 17a2 2 0 0 0 1.7 3h15a2 2 0 0 0 1.7-3L13.700 4.200a2 2 0 0 0-3.400 0z" /> : <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5" />}</svg>
</span>
<div style={{ minWidth: 0 }}>
<div className="gcprs-name">
<span>{file.name}</span>
<span className="gcprs-meta">{Math.round(st.p)}%</span>
</div>
<div className="gcprs-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(st.p)} aria-label={`Subida de ${file.name}`}>
<div className="gcprs-fill" style={{ width: `${st.p}%` }} />
</div>
<span className="gcprs-meta" role="status">{file.size} · {label[st.s]}</span>
</div>
{st.s === "error" ? (
<button className="gcprs-act" type="button" onClick={() => retry(file.id)}>Reintentar</button>
) : st.s === "up" ? (
<button className="gcprs-act" type="button" aria-label={`Cancelar ${file.name}`} onClick={() => cancel(file.id)}>Cancelar</button>
) : (
<span />
)}
</li>
);
})}
</ul>
<div className="gcprs-foot">
<button className="gcprs-act" type="button" onClick={() => { tries.current = {}; setState(fresh()); setRun((value) => value + 1); }}>Repetir demostración</button>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/progreso-subidaEjemplo de uso de Lista de subidas con progreso
Después de copiar progreso-subida.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ProgresoSubida } from "./progreso-subida";
export default function Ejemplo() {
return <ProgresoSubida />;
}Propiedades de ProgresoSubida
ProgresoSubida acepta 3 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
files? | ProgresoSubidaArchivo[] | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 131 líneas (7 KB) y no necesita @keyframes. Esto es lo que hace Lista de subidas con progreso 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: progressbar, status.
- Lee preferencias del navegador con useSyncExternalStore, sin parpadeos en la hidratación.
- Es un componente de cliente de Next.js: incluye la directiva "use client".