Botón HTML de subida con estilo propio y arrastrar y soltar
Aplica estilos al botón del selector de archivos nativo con ::file-selector-button para
conservar su comportamiento con el teclado y la visualización del archivo seleccionado.
Crearemos un formulario multipart que funcione sin JavaScript y luego añadiremos un área para
soltar archivos que coloque un archivo en ese mismo selector. Un receptor local informará qué
recibió.
Crea los archivos de la demo
Usa una versión de parche actual de Node.js, como mínimo la 24.15.0, de una
rama de versiones con mantenimiento, un navegador y un shell POSIX
como Bash en Linux, macOS o WSL. Este ejemplo usa las API integradas de Node; no necesita instalar
paquetes ni compilar. El receptor usa .mts para que Node lo ejecute como
módulo ES independientemente del package.json de un proyecto superior; consulta
las reglas de módulos TypeScript de Node.
Este tutorial se probó en Linux con Node.js 24.15.0, 26.5.0 y 26.8.1, y Chromium 145 y 152.
Desde un directorio donde guardes tus experimentos, pega lo siguiente:
(
mkdir native-upload &&
cd native-upload &&
touch index.html styles.css enhance.js server.mts
)
Los paréntesis mantienen el shell en su directorio original. Si ya existe un directorio
native-upload, este comando falla antes de modificar su contenido. Si falla,
resuelve el error o elige un nombre de directorio nuevo antes de continuar. Guarda los siguientes
ejemplos en los archivos dentro de native-upload. Deja
enhance.js vacío hasta el paso opcional de JavaScript.
Conserva el selector de archivos nativo
Guarda esta página completa como native-upload/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Native file upload</title>
<link rel="stylesheet" href="styles.css" />
<script src="enhance.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload" action="/upload"
method="post" enctype="multipart/form-data">
<section id="drop-area" aria-label="File selection">
<label for="file-input">Choose a file</label>
<p id="file-help">One file, up to 1 MiB. Nothing is saved.</p>
<input id="file-input" name="file" type="file" required
aria-describedby="file-help" />
<p id="drop-hint" hidden>Or drop one file here, then choose Upload.</p>
</section>
<p id="selection-status" role="status" aria-atomic="true"></p>
<button type="submit">Upload</button>
</form>
</main>
</body>
</html>
Hay un único selector de archivos, dentro del formulario que lo envía. Su
name="file" es el campo multipart que espera el receptor;
id="file-input" vincula la etiqueta. required impide que el
navegador envíe el formulario si el selector está vacío. Un archivo vacío seleccionado
intencionalmente sigue siendo un archivo y aquí se permite.
Mantén visible el selector. Ocultarlo con display: none lo elimina de la navegación
con teclado, y una etiqueta con estilo de botón no adquiere el comportamiento de teclado de un
botón. Para una explicación más completa de los atributos del formulario, consulta nuestro
tutorial de formularios HTML para subir archivos.
Aplica estilos al botón y al área para soltar archivos
Guarda lo siguiente como native-upload/styles.css:
:root { color-scheme: light dark; }
:root.dark { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 1rem/1.5 system-ui, sans-serif;
background: Canvas;
color: CanvasText;
}
main { max-width: 36rem; margin: 2rem auto; padding: 1.25rem; }
label { font-weight: 600; }
input[type='file'] { display: block; width: 100%; font: inherit; }
input[type='file']::file-selector-button,
button {
font: inherit;
padding: 0.6rem 0.9rem;
border: 1px solid ButtonText;
border-radius: 0.4rem;
background: ButtonFace;
color: ButtonText;
cursor: pointer;
}
input[type='file']::file-selector-button { margin-inline-end: 0.75rem; }
input:focus-visible,
button:focus-visible { outline: 3px solid Highlight; outline-offset: 4px; }
#drop-area { border: 2px dashed GrayText; padding: 1rem; }
#drop-area.dragover { border-color: Highlight; border-style: solid; }
#selection-status { overflow-wrap: anywhere; }
El pseudoelemento ::file-selector-button
aplica estilos al botón dentro del selector nativo. Establece su fuente explícitamente porque no
necesariamente hereda la del selector. El control que lo contiene sigue mostrando el nombre del
archivo y abre el cuadro de selección nativo. Estos colores del sistema siguen el esquema de
colores claro u oscuro del navegador, y una clase dark en
<html> selecciona explícitamente el esquema oscuro. El contorno de foco
pertenece al selector en sí, no a su etiqueta. El área con borde discontinuo es una superficie de
selección alternativa, por lo que no necesita otra parada en la navegación con Tab.
Sirve el formulario y recibe sus bytes
Guarda lo siguiente como native-upload/server.mts. Sirve los tres archivos del navegador y
acepta exactamente una parte multipart llamada file, de hasta 1 MiB.
Un límite independiente de 2 MiB restringe el cuerpo de la solicitud almacenado en el búfer,
incluidos los encabezados multipart y cualquier dato final, antes del análisis multipart.
El análisis y el cálculo del hash necesitan memoria adicional; esta es una pequeña demostración
local, no un límite de la memoria total del servidor.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port > 65535) {
throw new Error('Use --port with an integer from 0 to 65535.')
}
const maxFileBytes = 1024 * 1024
const maxRequestBytes = 2 * 1024 * 1024
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css; charset=utf-8'],
['/enhance.js', 'enhance.js', 'text/javascript; charset=utf-8'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
let origin = ''
function reply(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
response.end(`${message}\n\nNothing was saved. Use Back to return to the form.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.headers.host !== new URL(origin).host) {
request.resume()
reply(response, 403, 'Use the printed loopback URL.')
return
}
const asset = assets.get(request.url ?? '')
if (request.method === 'GET' && asset) {
response.writeHead(200, { 'Content-Type': asset.type })
response.end(asset.body)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
request.resume()
reply(response, 404, 'Route not found.')
return
}
if (request.headers.origin !== origin) {
request.resume()
reply(response, 403, 'Submit the form served by this receiver.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Preserve the connection for readable feedback when stopping the read loop early.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxRequestBytes) {
request.resume()
reply(response, 413, 'Request exceeds 2 MiB. Choose a smaller file.')
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': request.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form.')
return
}
const file = data.get('file')
if ([...data.keys()].length !== 1 || !(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose exactly one file in the file field.')
return
}
if (file.size > maxFileBytes) {
reply(response, 413, 'File exceeds 1 MiB. Choose a smaller file.')
return
}
const receipt = {
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex'),
}
reply(response, 200, `Received one file.\n${JSON.stringify(receipt, null, 2)}`)
}
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Upload handler failed.')
if (!response.headersSent) reply(response, 500, 'Could not process the upload.')
else response.destroy()
})
})
server.requestTimeout = 30000
server.on('error', () => {
console.error('Could not start the receiver. Check that the port is available.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}/`)
})
El servidor almacena una solicitud pequeña en el búfer y luego usa
Request.formData() de Node para analizarla. La
opción destroyOnReturn: false del flujo
le permite dejar de almacenar en el búfer un cuerpo demasiado grande mientras devuelve una
respuesta HTTP 413. El resto se descarta. El hash del contenido del archivo se calcula en memoria,
y dicho contenido nunca se escribe en disco.
Ejecuta el formulario sin JavaScript
Desde el directorio que contiene native-upload, inicia el receptor:
node native-upload/server.mts
Abre la URL exacta que aparece en la terminal. El servidor elige un puerto disponible en
127.0.0.1; abrir index.html directamente no conectará
/upload con él. Detén el servidor con Ctrl+C cuando termines.
Reinícialo después de editar un archivo, porque los archivos del navegador se cargan al iniciarse.
Si necesitas un puerto fijo, añade --port 8123 al mismo comando; si el puerto
está ocupado, falla con un estado distinto de cero.
Con JavaScript desactivado, recarga la página y pulsa Tab hasta llegar al selector etiquetado como
Choose a file. Su contorno debería ser visible.
Pulsa la barra espaciadora para abrir el cuadro de selección, selecciona un archivo pequeño y luego
pulsa Tab hasta llegar a Upload y pulsa
Enter. El navegador navega a /upload, donde aparece
Received one file. con el nombre del archivo, el número
de bytes y el hash SHA-256. Esa respuesta confirma la recepción; ver un nombre de archivo en el
selector confirma la selección. Usa Atrás para volver. Al enviar de nuevo, el archivo se vuelve a
inspeccionar sin guardarlo.
Añade arrastrar y soltar sin cambiar el envío
Guarda esta mejora opcional como native-upload/enhance.js, reinicia el receptor y recarga
con JavaScript activado:
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const area = document.getElementById('drop-area')
const status = document.getElementById('selection-status')
const hint = document.getElementById('drop-hint')
const maxFileBytes = 1024 * 1024
function checkSelection(files) {
const file = files[0]
if (files.length !== 1 || !file) {
input.value = ''
status.textContent = 'Choose exactly one file.'
return false
}
if (file.size > maxFileBytes) {
input.value = ''
status.textContent = 'File exceeds 1 MiB. Choose a smaller file.'
return false
}
status.textContent = `Selected: ${file.name}. Choose Upload to send it.`
return true
}
input.addEventListener('change', () => checkSelection(input.files))
form.addEventListener('submit', (event) => {
if (!checkSelection(input.files)) event.preventDefault()
})
area.addEventListener('dragover', (event) => {
event.preventDefault()
area.classList.add('dragover')
})
area.addEventListener('dragleave', () => area.classList.remove('dragover'))
area.addEventListener('drop', (event) => {
event.preventDefault()
area.classList.remove('dragover')
if (!event.dataTransfer || !checkSelection(event.dataTransfer.files)) return
try {
input.files = event.dataTransfer.files
} catch {
input.value = ''
status.textContent = 'Could not select this drop. Use the file picker.'
}
})
hint.hidden = false
Lee dataTransfer.files
dentro del controlador de drop. Asignar ese
FileList a input.files cambia la selección del selector
nativo, tal como especifica el
estándar HTML.
Cambiar el value del selector por una ruta del sistema de archivos no
permite seleccionar un archivo.
Soltar un archivo normal solo lo selecciona. El estado muestra el nombre del archivo y dice Choose Upload to send it.. Elige Upload para enviar el mismo formulario multipart. Seleccionar otro archivo reemplaza el primero. Soltar un archivo demasiado grande o varios archivos borra la selección anterior y te pide que elijas de nuevo. Si falla la asignación del archivo soltado, usa el cuadro de selección nativo. Las carpetas quedan fuera del alcance de este ejemplo.
Aquí no hay una vía de envío con fetch() ni XHR: los envíos válidos usan la
navegación del navegador, tanto con JavaScript activado como desactivado. Si quieres progreso,
reintentos y una interfaz que permanezca en la página, usa nuestro
tutorial de un cargador de archivos personalizado con JavaScript.
Comprueba los rechazos antes de adaptar el formulario
Intenta enviar el formulario con el selector vacío: el navegador debería pedir un archivo.
Con JavaScript activado, seleccionar un archivo de más de 1 MiB borra la selección y muestra
File exceeds 1 MiB. Choose a smaller file..
Desactiva JavaScript y envía ese archivo de nuevo: el receptor debería rechazarlo con HTTP 413.
Usa Atrás y selecciona un archivo más pequeño para continuar. Un archivo seleccionado de cero
bytes se recibe correctamente con bytes: 0.
El receptor también rechaza los campos de archivo ausentes, repetidos o con otro nombre con HTTP 400, los cuerpos multipart malformados con HTTP 400 y las solicitudes que superan su límite de búfer de 2 MiB con HTTP 413. HTTP 403 significa que el host o el origen de la solicitud no coincidía con la URL mostrada en la terminal. Mantén tanto el formulario como el receptor en esa URL.
Este receptor local acepta cualquier tipo de archivo y descarta todas las subidas. No verifica
que un archivo sea una imagen, un documento o que sea seguro procesarlo. Un
atributo accept
puede orientar al selector, pero no puede validar el contenido. Antes de conectar este formulario
a un servicio público, usa el endpoint de subida autenticado de ese servicio y sus comprobaciones
de tamaño y contenido del lado del servidor. Mantén el nombre de campo del selector en
concordancia con el campo que espera el receptor.
