Crear una interfaz de subida personalizada con JavaScript
Conserva el campo de archivo nativo y añade lo que no ofrece: vistas previas de imágenes, una lista de selección con elementos eliminables y mensajes cuando no se puede leer una imagen. Este ejemplo te permite reemplazar o borrar una selección mientras se cargan las vistas previas, sin que un resultado anterior cambie la nueva lista. Los archivos permanecen en el navegador; esta página no los envía.
Decide qué gestiona tu interfaz de subida de archivos
Un campo nativo ya permite la selección de varios archivos.
Lo mantendremos visible para el acceso por teclado y crearemos una lista de vistas previas a partir
de sus objetos File.
Elegir o soltar archivos reemplaza la selección anterior.
Remove elimina una fila y
Clear selection las elimina todas. Cancelar el selector
deja la lista intacta.
La demo acepta hasta 10 imágenes JPEG, PNG o GIF no vacías a la vez, de hasta 2 MiB cada una.
Comprueba el tipo MIME declarado, una firma breve y si el navegador puede decodificar una vista previa.
Estas comprobaciones informan sobre la selección, pero no demuestran que el contenido sea seguro. El
atributo accept solo es una sugerencia para el selector.
Un archivo comprimido pequeño puede decodificarse en una imagen grande; el límite de bytes no limita
la memoria que ocupa la imagen decodificada.
Necesitas un navegador, un intérprete de comandos POSIX como Bash y Node.js 24.15 o posterior de la
rama 24.x, o Node.js 26.5 o posterior de la rama 26.x. No se necesitan dependencias ni una etapa de compilación.
Conserva la extensión .mts del servidor:
Node lo trata como un módulo ES
incluso dentro de un proyecto CommonJS.
Desde el directorio donde guardas tus experimentos, crea una carpeta nueva:
mkdir file-preview-demo
Si ese directorio ya existe, detente y elige otro nombre en lugar de sobrescribir sus archivos.
Guarda los siguientes cuatro ejemplos dentro de file-preview-demo con los nombres de
archivo indicados.
Configura la estructura HTML
Guarda el archivo como index.html. El campo nativo con etiqueta es la alternativa
por teclado a soltar archivos. La lista conserva los mensajes de cada archivo y la región de estado
no intrusiva informa sobre la selección actual.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image selection preview</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Preview selected images</h1>
<section id="drop-zone" aria-label="Drop images">
<label for="file-input">Choose images</label>
<input id="file-input" type="file" multiple accept="image/jpeg,image/png,image/gif"
aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 JPEG, PNG, or GIF images, each up to 2 MiB.</p>
</section>
<button id="clear-selection" type="button" disabled>Clear selection</button>
<p id="status" role="status" aria-atomic="true">No files selected. Nothing has been uploaded.</p>
<ul id="file-preview" aria-label="Selected images"></ul>
<noscript>Enable JavaScript to preview and manage your selection.</noscript>
</main>
</body>
</html>
Aplica estilos al campo de archivo con CSS
Guarda el archivo como styles.css. Puedes aplicar estilos al botón del selector
sin ocultar el campo. Los colores del sistema siguen el tema claro u oscuro del navegador y los
nombres de archivo largos se ajustan en varias líneas dentro de cada fila.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 42rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.dragover { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-block: 0.5rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#file-preview { list-style: none; padding: 0; }
#file-preview li { border-block-start: 1px solid currentColor; padding-block: 1rem; }
#file-preview p { overflow-wrap: anywhere; }
#file-preview img { width: 8rem; height: 8rem; object-fit: contain; max-width: 100%; }
Implementa JavaScript para ampliar la funcionalidad
Guarda el archivo como script.js. Cada fila gestiona su lector y su URL de objeto.
Al descartar una fila, se marca como inactiva antes de abortar una lectura pendiente y liberar su URL.
Ambas etapas asíncronas comprueban esa marca antes de cambiar el estado visible, por lo que una
lectura de firma o una decodificación de imagen tardías no pueden reactivar una fila eliminada.
FileReader.abort()
cancela una lectura pendiente; al manejar su evento de cancelación también se resuelve nuestra
promesa. Aquí la decodificación de imágenes no tiene un método de cancelación equivalente, así que
descartamos su resultado si la fila ya se ha descartado.
img.decode()
nos permite mostrar la miniatura solo después de que la decodificación se complete correctamente.
const fileInput = document.getElementById('file-input')
const preview = document.getElementById('file-preview')
const clearButton = document.getElementById('clear-selection')
const dropZone = document.getElementById('drop-zone')
const status = document.getElementById('status')
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
const maxSize = 2 * 1024 * 1024
let entries = []
function updateStatus() {
clearButton.disabled = entries.length === 0
const ready = entries.filter((entry) => entry.state === 'ready').length
const checking = entries.filter((entry) => entry.state === 'checking').length
const rejected = entries.filter((entry) => entry.state === 'rejected').length
status.textContent = entries.length === 0
? 'No files selected. Nothing has been uploaded.'
: `${ready} previews ready, ${checking} checking, ${rejected} rejected. Nothing has been uploaded.`
}
function dispose(entry) {
entry.active = false
if (entry.reader.readyState === FileReader.LOADING) entry.reader.abort()
if (entry.url !== null) URL.revokeObjectURL(entry.url)
entry.url = null
entry.image.removeAttribute('src')
}
function clearSelection() {
for (const entry of entries) dispose(entry)
entries = []
preview.replaceChildren()
fileInput.value = ''
updateStatus()
}
function removeEntry(entry) {
const index = entries.indexOf(entry)
const focusTarget = entries[index + 1]?.remove ?? entries[index - 1]?.remove ?? fileInput
entries.splice(index, 1)
dispose(entry)
entry.row.remove()
updateStatus()
focusTarget.focus()
}
function createEntry(file) {
const row = document.createElement('li')
const image = document.createElement('img')
image.alt = `Preview of ${file.name}`
image.hidden = true
const name = document.createElement('p')
name.textContent = file.name
const message = document.createElement('p')
message.textContent = 'Checking image…'
const remove = document.createElement('button')
remove.type = 'button'
remove.textContent = 'Remove'
remove.setAttribute('aria-label', `Remove ${file.name}`)
const entry = {
file, row, image, message, remove,
reader: new FileReader(), url: null, active: true, state: 'checking',
}
remove.addEventListener('click', () => removeEntry(entry))
row.append(image, name, message, remove)
preview.appendChild(row)
return entry
}
function finish(entry, state, message) {
entry.state = state
entry.message.textContent = message
updateStatus()
}
function checkFileSignature(file, reader = new FileReader()) {
return new Promise((resolve) => {
function complete(valid) {
reader.onload = reader.onerror = reader.onabort = null
resolve(valid)
}
reader.onerror = reader.onabort = () => complete(false)
reader.onload = () => {
const bytes = new Uint8Array(reader.result)
if (bytes.length < 4) return complete(false)
const header = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
const types = { '89504e47': 'image/png', '47494638': 'image/gif' }
const detectedType = header.startsWith('ffd8ff') ? 'image/jpeg' : types[header]
complete(detectedType === file.type)
}
reader.readAsArrayBuffer(file.slice(0, 4))
})
}
async function loadPreview(entry) {
const valid = await checkFileSignature(entry.file, entry.reader)
if (!entry.active) return
if (!valid) {
finish(entry, 'rejected', 'Cannot read this file, or its signature does not match its MIME type.')
return
}
entry.url = URL.createObjectURL(entry.file)
entry.image.src = entry.url
try {
await entry.image.decode()
if (!entry.active) return
entry.image.hidden = false
finish(entry, 'ready', 'Preview ready.')
} catch {
if (!entry.active) return
URL.revokeObjectURL(entry.url)
entry.url = null
entry.image.removeAttribute('src')
finish(entry, 'rejected', 'Image could not be decoded. Choose another file.')
}
}
function choose(files) {
if (files.length === 0) return
clearSelection()
if (files.length > 10) {
status.textContent = 'Choose no more than 10 files at once. Nothing has been uploaded.'
return
}
for (const file of files) {
const entry = createEntry(file)
entries.push(entry)
if (!allowedTypes.includes(file.type)) {
finish(entry, 'rejected', 'Choose a JPEG, PNG, or GIF with a recognized MIME type.')
} else if (file.size === 0 || file.size > maxSize) {
finish(entry, 'rejected', 'The image must be nonempty and no larger than 2 MiB.')
} else {
void loadPreview(entry)
}
}
updateStatus()
}
fileInput.addEventListener('change', () => {
choose(Array.from(fileInput.files))
// Retain the File objects ourselves so choosing the same files can fire change again.
fileInput.value = ''
})
clearButton.addEventListener('click', () => {
clearSelection()
fileInput.focus()
})
dropZone.addEventListener('dragover', (event) => {
event.preventDefault()
dropZone.classList.add('dragover')
})
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'))
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
dropZone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
window.addEventListener('pagehide', clearSelection)
Los nombres de archivo pasan por textContent, de modo que un nombre que contenga
HTML se muestra como texto.
Mantén activas las URL de objeto mientras sus imágenes estén disponibles.
Remove, Clear selection,
el reemplazo, un fallo de decodificación y salir de la página las liberan.
Al recargar la página o salir de ella, se pierde la selección.
La comprobación de cuatro bytes reconoce solo un prefijo. Incluso una decodificación correcta en el navegador puede tolerar un archivo dañado; ninguna de las dos comprobaciones demuestra que la imagen esté intacta o sea segura para almacenarla. Las filas rechazadas siguen visibles con su explicación y aún se pueden eliminar.
Sirve la página de vistas previas localmente
Guarda el archivo como server.mts. Este servidor sirve solo los tres archivos del
navegador y no tiene un endpoint de subida. De forma predeterminada, selecciona un puerto de bucle
local disponible; opcionalmente, configura PORT si necesitas uno fijo.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
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'],
['/script.js', 'script.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
return
}
const asset = assets.get(req.url ?? '')
if (req.method !== 'GET' || !asset) {
res.writeHead(404).end('Not found.')
return
}
res.writeHead(200, { 'Content-Type': asset.type, 'Cache-Control': 'no-store' })
res.end(asset.body)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start preview server. Check PORT and the three browser files.')
process.exitCode = 1
})
Desde el directorio padre donde creaste file-preview-demo, ejecuta:
(cd file-preview-demo && node server.mts)
Abre la URL que aparece en la terminal en tu navegador. Mantén la terminal abierta y detén el servidor
con Ctrl+C cuando termines; tu intérprete de comandos permanece en el directorio padre. Reinicia el
servidor después de editar un archivo del navegador, porque lee esos archivos al iniciarse. Un puerto
ocupado, un valor de PORT no válido o un archivo faltante detienen el inicio
con un código de estado distinto de cero en lugar de mostrar una URL lista para usar.
Compatibilidad con navegadores
Este tutorial se probó en Linux con Node.js 24.15.0, 26.5.0 y 26.8.1, y Chromium 145 y 152.
Utiliza FileReader nativo, URL de objeto, arrastrar y soltar y
img.decode(); el selector sigue siendo la alternativa en dispositivos donde no es
posible soltar archivos.
Prueba el flujo completo de selección:
- Usa Tab para llegar a Choose images, abre el selector y selecciona dos imágenes pequeñas. Cada fila debería mostrar su nombre de archivo, una miniatura y Preview ready. El estado debería indicar 2 previews ready, 0 checking, 0 rejected. Nothing has been uploaded.
- Usa Tab para llegar al botón Remove de una fila y actívalo. El foco pasa al botón Remove de la fila siguiente, al de la anterior si no hay una fila siguiente, o al campo de archivo si la lista está vacía. Clear selection también devuelve el foco al campo de archivo.
- Elige o suelta una selección diferente mientras se cargan las vistas previas. Las filas anteriores desaparecen y no pueden volver cuando terminan sus lecturas. Elige de nuevo los mismos archivos para confirmar que puedes volver a seleccionarlos; cancelar el selector debería conservar la lista actual.
- Prueba con una imagen vacía, un tipo no compatible, un archivo de más de 2 MiB y una imagen dañada con un prefijo reconocible. Sus filas deberían mostrar explicaciones. Si eliges más de 10 archivos, se rechaza toda esa selección.
Conecta la selección a un componente de subida
Esta lista gestiona sus objetos File y restablece el campo nativo después de
cada selección. Por lo tanto, enviar ese campo como parte de un formulario no enviaría ningún archivo
seleccionado. Para añadir la entrega, envía los archivos aceptados que se hayan conservado y coordina
la eliminación o el reemplazo con las solicitudes pendientes.
Para ver un ejemplo completo de envío y recepción, continúa con el componente de subida personalizado con JavaScript. Maneja un JPEG, PNG o PDF por solicitud con un comprobante que incluye una suma de comprobación; su receptor no es un endpoint para varias imágenes y no acepta GIF. Trátalo como un tutorial aparte, no como un receptor listo para conectar a esta lista de vistas previas. Cualquier servicio público de subida necesita su propia validación y autorización del lado del servidor; esta interfaz local no ofrece ni aceptación del servidor ni protección contra CSRF.
