Botão de upload HTML personalizado com arrastar e soltar arquivos
Estilize o botão do campo de arquivo nativo com ::file-selector-button para manter o comportamento de
teclado e a exibição do arquivo selecionado. Vamos construir um formulário multipart que funciona sem
JavaScript e depois adicionar uma área de soltar que coloca um arquivo nesse mesmo campo. Um receptor
local vai informar o que chegou.
Crie os arquivos da demonstração
Use uma versão de patch atual do Node.js, no mínimo 24.15.0, em uma
linha de versões mantida, um navegador e um shell POSIX,
como Bash no Linux, macOS ou WSL. Este exemplo usa as APIs integradas do Node; não precisa de
instalação de pacotes nem de compilação. O receptor usa .mts para que o Node o execute como
módulo ES, independentemente do package.json de um projeto pai; consulte as
regras de módulos TypeScript do Node.
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.
A partir de um diretório onde você guarda experimentos, cole:
(
mkdir native-upload &&
cd native-upload &&
touch index.html styles.css enhance.js server.mts
)
Os parênteses mantêm seu shell no diretório original. Se já existir um diretório native-upload,
este comando falha antes de mexer no conteúdo dele. Se falhar, resolva o erro ou escolha um novo
nome de diretório antes de continuar. Salve os exemplos a seguir nos arquivos dentro de
native-upload. Deixe enhance.js vazio até a etapa opcional de JavaScript.
Mantenha o campo de arquivo nativo
Salve 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>
Há um único campo de arquivo, dentro do formulário que o envia. Seu name="file" é o campo
multipart que o receptor espera; id="file-input" conecta o rótulo. required impede que o
navegador envie o formulário com o seletor vazio. Um arquivo vazio selecionado deliberadamente ainda
é um arquivo e é permitido aqui.
Mantenha o campo visível. Ocultá-lo com display: none o remove da navegação por teclado, e um
rótulo estilizado para parecer um botão não ganha o comportamento de teclado de um botão. Para uma
explicação mais completa dos atributos do formulário, veja nosso
tutorial de formulário de upload de arquivos em HTML.
Estilize o botão e a área de soltar
Salve isto 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; }
O pseudoelemento ::file-selector-button
estiliza o botão dentro do campo nativo. Defina a fonte dele explicitamente, porque ele não herda
necessariamente a fonte do campo. O controle ao redor continua exibindo o nome do arquivo e abrindo o
seletor nativo. Essas cores do sistema seguem o esquema de cores claro ou escuro do navegador, e uma
classe dark em <html> seleciona o esquema escuro explicitamente. O contorno de
foco pertence ao próprio campo, não ao rótulo dele. A área tracejada é uma superfície alternativa de
seleção, então não precisa de outra parada de tabulação.
Sirva o formulário e receba seus bytes
Salve isto como native-upload/server.mts. Ele serve os três arquivos para o navegador e aceita exatamente
uma parte multipart chamada file, de até 1 MiB. Um limite separado de 2 MiB restringe o
corpo da requisição armazenado em buffer, incluindo os cabeçalhos multipart e quaisquer dados ao
final, antes da análise multipart. A análise e o cálculo do hash precisam de memória adicional; esta
é uma pequena demonstração local, não um limite para a memória total do 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}/`)
})
O servidor armazena em buffer uma requisição pequena e depois usa o Request.formData() do Node para
analisá-la. A opção destroyOnReturn: false do stream
permite que ele pare de armazenar em buffer um corpo grande demais enquanto retorna uma resposta
HTTP 413. O restante é descartado. O conteúdo dos arquivos tem o hash calculado em memória e nunca é
gravado em disco.
Execute o formulário sem JavaScript
No diretório que contém native-upload, inicie o receptor:
node native-upload/server.mts
Abra a URL exata exibida no terminal. O servidor escolhe uma porta disponível em 127.0.0.1;
abrir index.html diretamente não conectará /upload a ele. Pare-o com Ctrl+C ao terminar.
Reinicie-o depois de editar um arquivo, porque os recursos do navegador são carregados na
inicialização. Se precisar de uma porta fixa, acrescente --port 8123 ao mesmo comando; se a porta
estiver ocupada, o comando falha com um status de saída diferente de zero.
Com o JavaScript desativado, recarregue a página e use Tab até chegar ao campo rotulado
Choose a file. O contorno dele deve estar visível. Pressione
Espaço para abrir o seletor, selecione um arquivo pequeno, depois use Tab até
Upload e pressione Enter. O navegador navega para
/upload, onde Received one file. aparece com o nome do
arquivo, a contagem de bytes e o hash SHA-256. Essa resposta confirma o recebimento; ver um nome de
arquivo no seletor confirma a seleção. Use Voltar para retornar. Enviar novamente inspeciona o
arquivo outra vez sem salvá-lo.
Adicione arrastar e soltar sem mudar o envio
Salve este aprimoramento opcional como native-upload/enhance.js, reinicie o receptor e recarregue a
página com o JavaScript ativado:
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
Leia dataTransfer.files
dentro do manipulador drop. Atribuir esse FileList a input.files altera a seleção
do campo nativo, conforme especificado pelo
padrão HTML.
Alterar o value do campo para um caminho do sistema de arquivos não consegue selecionar um
arquivo.
Soltar um arquivo comum apenas o seleciona. O status mostra o nome do arquivo e diz Choose Upload to send it.. Escolha Upload para enviar o mesmo formulário multipart. Selecionar outro arquivo substitui o primeiro. Soltar um arquivo grande demais ou vários arquivos de uma vez limpa a seleção anterior e pede que você escolha novamente. Se a atribuição do arquivo solto falhar, use o seletor nativo. Pastas estão fora do escopo deste exemplo.
Não há caminho com fetch() nem XHR aqui: envios válidos usam a navegação do navegador com o
JavaScript ativado ou desativado. Se você quiser progresso, novas tentativas e uma interface que
permaneça na página, use nosso
tutorial de componente de upload personalizado em JavaScript.
Verifique a rejeição antes de adaptar o formulário
Tente enviar com o seletor vazio: o navegador deve pedir um arquivo. Com o JavaScript ativado,
selecionar um arquivo acima de 1 MiB limpa a seleção e mostra
File exceeds 1 MiB. Choose a smaller file..
Desative o JavaScript e envie esse arquivo novamente: o receptor deve rejeitá-lo com HTTP 413.
Use Voltar e selecione um arquivo menor para se recuperar. Um arquivo de zero bytes selecionado é
aceito com bytes: 0.
O receptor também rejeita campos de arquivo ausentes, repetidos ou com outro nome com HTTP 400, corpos multipart malformados com HTTP 400 e requisições acima do limite de buffer de 2 MiB com HTTP 413. HTTP 403 significa que o host ou a origem da requisição não correspondeu à URL exibida. Mantenha o formulário e o receptor nessa URL.
Este receptor local aceita qualquer tipo de arquivo e descarta todos os uploads. Ele não verifica se
um arquivo é uma imagem, um documento ou seguro para processar. Um
atributo accept
pode orientar o seletor, mas não consegue validar o conteúdo. Antes de conectar este formulário a um
serviço público, use o endpoint de upload autenticado desse serviço e as verificações de tamanho e
de conteúdo que ele faz no lado do servidor. Mantenha o nome do campo de arquivo igual ao campo que o
receptor espera.
