Criando uma UI personalizada de upload de arquivos com JavaScript
Mantenha o input de arquivo nativo e adicione o que ele não oferece: prévias de imagens, uma lista de seleção removível e um aviso quando uma imagem não pode ser lida. Com este exemplo, você pode substituir ou limpar uma seleção enquanto as prévias ainda estão carregando, sem que um resultado antigo altere a nova lista. Os arquivos ficam no navegador; esta página não os envia.
Defina o que sua UI de upload de arquivos controla
Um input nativo já oferece suporte à seleção de vários arquivos.
Vamos mantê-lo visível para o acesso por teclado e montar uma lista de prévias em torno dos seus
objetos File.
Escolher ou soltar arquivos substitui a seleção anterior.
Remove exclui uma linha, e
Clear selection exclui todas. Cancelar o seletor
mantém a lista como está.
A demonstração aceita até 10 imagens JPEG, PNG ou GIF não vazias de uma vez, cada uma com no máximo
2 MiB. Ela verifica o tipo MIME declarado, uma assinatura curta e se o navegador consegue decodificar
uma prévia. Essas verificações servem como retorno sobre a seleção, não como prova de conteúdo
seguro. O atributo accept é apenas uma sugestão para o seletor.
Um arquivo pequeno e compactado ainda pode ser decodificado em uma imagem grande; o limite de bytes
não restringe a memória da imagem decodificada.
Você precisa de um navegador, um shell POSIX como o Bash e o Node.js 24.15 ou mais recente na linha
24.x, ou 26.5 ou mais recente na linha 26.x. Não são necessárias dependências nem etapa de build.
Mantenha a extensão .mts do servidor: o Node a trata como um módulo ES
mesmo dentro de um projeto CommonJS.
No diretório onde você guarda seus experimentos, crie uma nova pasta:
mkdir file-preview-demo
Se esse diretório já existir, pare e escolha um novo nome em vez de sobrescrever os arquivos dele.
Salve os quatro exemplos a seguir dentro de file-preview-demo com os nomes de arquivo
indicados.
Configurando a estrutura HTML
Salve como index.html. O input nativo com rótulo é a alternativa por teclado a
soltar arquivos. A lista mantém o retorno de cada arquivo, e a região de status, que faz seus
anúncios sem interromper o usuário, informa a seleção atual.
<!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>
Estilizando o input de arquivo com CSS
Salve como styles.css. O botão do seletor do input pode ser estilizado sem
esconder o input. As cores do sistema seguem o tema claro ou escuro do navegador, e nomes de arquivo
longos quebram linha dentro de cada linha da lista.
: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%; }
Implementando o JavaScript para ampliar a funcionalidade
Salve como script.js. Cada linha é dona do seu leitor e da sua URL de objeto.
Descartar uma linha a marca como inativa antes de abortar uma leitura pendente e liberar a URL. As
duas etapas assíncronas verificam essa flag antes de alterar o estado visível, então uma leitura de
assinatura ou uma decodificação de imagem atrasada não consegue reviver uma linha removida.
FileReader.abort()
cancela uma leitura pendente; tratar o evento de abort também resolve a nossa promise. Aqui, a
decodificação de imagem não tem um método de cancelamento equivalente, então descartamos o resultado
se a linha tiver sido descartada.
img.decode()
permite mostrar a miniatura somente depois que a decodificação for bem-sucedida.
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)
Os nomes de arquivo passam por textContent, então um nome que contém HTML é
exibido como texto.
Mantenha as URLs de objeto ativas enquanto suas imagens estiverem disponíveis.
Remove, Clear selection,
a substituição, uma falha de decodificação e sair da página as liberam.
Recarregar ou sair da página faz a seleção ser esquecida.
A verificação de quatro bytes reconhece apenas um prefixo. Mesmo uma decodificação bem-sucedida no navegador pode tolerar um arquivo danificado; nenhuma das etapas prova que a imagem está íntegra ou que é seguro armazená-la. As linhas rejeitadas continuam visíveis com a explicação e ainda podem ser removidas.
Sirva a página de prévia localmente
Salve como server.mts. Ele serve apenas os três arquivos do navegador e não tem
endpoint de upload. Por padrão, ele seleciona uma porta de loopback disponível; se precisar de uma
porta fixa, você pode definir PORT.
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
})
A partir do diretório pai onde você criou file-preview-demo, execute:
(cd file-preview-demo && node server.mts)
Abra no navegador a URL exibida. Mantenha o terminal aberto e, ao terminar, pare o servidor com
Ctrl+C; seu shell continua no diretório pai. Reinicie o servidor depois de editar um arquivo do
navegador, porque ele lê esses arquivos na inicialização. Uma porta ocupada, um
PORT inválido ou um arquivo ausente interrompe a inicialização com status
de saída diferente de zero, em vez de exibir uma URL pronta.
Compatibilidade com navegadores
O passo a passo foi testado no Linux com Node.js 24.15.0, 26.5.0 e 26.8.1, e com Chromium 145 e
152. Ele usa FileReader nativo, URLs de objeto, arrastar e soltar e
img.decode(); o seletor continua sendo a alternativa em dispositivos onde não é
possível soltar arquivos.
Experimente o fluxo completo de seleção:
- Use Tab até Choose images, abra o seletor e escolha duas imagens pequenas. Cada linha deve mostrar o nome do arquivo, uma miniatura e Preview ready. O status deve informar 2 previews ready, 0 checking, 0 rejected. Nothing has been uploaded.
- Use Tab até o botão Remove de uma linha e ative-o. O foco vai para o botão Remove da próxima linha, depois para o da linha anterior se não houver próxima linha, ou para o input de arquivo se a lista estiver vazia. Clear selection também devolve o foco ao input de arquivo.
- Escolha ou solte uma seleção diferente enquanto as prévias estão carregando. As linhas anteriores desaparecem e não podem voltar quando suas leituras terminarem. Escolha os mesmos arquivos de novo para confirmar a nova seleção; cancelar o seletor deve manter a lista atual.
- Teste uma imagem vazia, um tipo não suportado, um arquivo maior que 2 MiB e uma imagem danificada com um prefixo reconhecível. As linhas desses arquivos devem mostrar explicações. Escolher mais de 10 arquivos rejeita a seleção inteira.
Conecte a seleção a um componente de upload
Esta lista é dona dos seus objetos File e redefine o input nativo depois
de cada escolha. Por isso, enviar esse input como formulário não enviaria nenhum arquivo selecionado.
Para adicionar o envio, envie os arquivos aceitos e retidos e coordene a remoção ou substituição com
as requisições pendentes.
Para um exemplo completo de envio e recebimento, continue com o componente de upload personalizado em JavaScript. Ele processa um JPEG, PNG ou PDF por requisição, com um recibo de checksum; o receptor dele não é um endpoint para várias imagens e não aceita GIF. Trate-o como um passo a passo separado, não como um receptor pronto para encaixar nesta lista de prévias. Qualquer serviço público de upload precisa da própria validação e autorização no lado do servidor; esta UI local não oferece aceitação pelo servidor nem proteção contra CSRF.
