Custom HTML file upload button with drag-and-drop
Style the native file input’s button with ::file-selector-button to keep its keyboard behavior
and selected-file display. We will build one multipart form that works without JavaScript, then
add a drop area that puts a file into that same input. A local receiver will report what arrived.
Create the demo files
Use a current patch release of Node.js, at least 24.15.0, in a
maintained release line, a browser, and a POSIX shell
such as Bash on Linux, macOS, or WSL. This example uses Node’s built-in APIs; it needs no package
installation or compilation. The receiver uses .mts so Node runs it as an ES module regardless
of a parent project’s package.json; see
Node’s TypeScript module rules.
The walkthrough was tested on Linux with Node.js 24.15.0, 26.5.0, and 26.8.1, and Chromium 145 and 152.
From a directory where you keep experiments, paste:
(
mkdir native-upload &&
cd native-upload &&
touch index.html styles.css enhance.js server.mts
)
The parentheses keep your shell in its original directory. An existing native-upload directory
makes this command fail before touching its contents. If it fails, resolve the error or choose a
new directory name before continuing. Save the following examples in the files inside
native-upload. Leave enhance.js empty until the optional JavaScript step.
Keep the native file input
Save this complete page as 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>
There is one file input, inside the form that submits it. Its name="file" is the multipart
field the receiver expects; id="file-input" connects the label. required stops an empty
picker from submitting through the browser. A deliberately selected empty file is still a file
and is allowed here.
Keep the input visible. Hiding it with display: none removes it from keyboard navigation,
and a label styled to look like a button does not acquire button keyboard behavior. For a fuller
explanation of the form attributes, see our
HTML file upload form tutorial.
Style the button and drop area
Save this as 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; }
The ::file-selector-button pseudo-element
styles the button inside the native input. Set its font explicitly because it does not necessarily
inherit the input’s font. The surrounding control still displays the filename and opens the native
picker. These system colors follow the browser’s light or dark color scheme, and a dark class
on <html> selects the dark scheme explicitly. The focus outline belongs to the actual input,
not its label. The dashed area is an alternative selection surface, so it does not need another
tab stop.
Serve the form and receive its bytes
Save this as native-upload/server.mts. It serves the three browser files and accepts exactly one
multipart part named file, up to 1 MiB. A separate 2 MiB limit caps the buffered request body,
including multipart headers and any trailing data, before multipart parsing. Parsing and hashing need
additional memory; this is a small local demonstration, not a limit on total server memory.
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}/`)
})
The server buffers a small request, then uses Node’s Request.formData() to parse it. The
stream’s destroyOnReturn: false option
lets it stop buffering an oversized body while returning an HTTP 413 response. The remainder is
discarded. File contents are hashed in memory and never written to disk.
Run the form without JavaScript
From the directory that contains native-upload, start the receiver:
node native-upload/server.mts
Open the exact URL printed in the terminal. The server chooses an available port on 127.0.0.1;
opening index.html directly will not connect /upload to it. Stop it with Ctrl+C when finished.
Restart it after editing a file because the browser assets are loaded at startup. If you need a
fixed port, append --port 8123 to the same command; an occupied port fails with a nonzero status.
With JavaScript disabled, reload the page and Tab to the input labeled
Choose a file. Its outline should be visible. Press Space to
open the picker, select a small file, then Tab to Upload and
press Enter. The browser navigates to /upload, where
Received one file. appears with the filename, byte count, and
SHA-256 hash. That response confirms receipt; seeing a filename in the picker confirms selection.
Use Back to return. Submitting again inspects the file again without saving it.
Add drag-and-drop without changing submission
Save this optional enhancement as native-upload/enhance.js, restart the receiver, and reload
with JavaScript enabled:
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
Read dataTransfer.files
inside the drop handler. Assigning that FileList to input.files changes the native input’s
selection, as specified by the
HTML standard.
Changing the input’s value to a filesystem path cannot select a file.
Dropping an ordinary file only selects it. The status names the file and says Choose Upload to send it.. Choose Upload to send the same multipart form. Selecting another file replaces the first. An oversized drop or a drop containing several files clears the previous selection and asks you to choose again. If assigning the drop fails, use the native picker. Folders are outside this example’s scope.
There is no fetch() or XHR path here: valid submissions use browser navigation with JavaScript
enabled or disabled. If you want progress, retries, and an interface that stays on the page, use
our custom JavaScript uploader tutorial.
Check rejection before adapting the form
Try submitting with an empty picker: the browser should ask for a file. With JavaScript enabled,
selecting a file over 1 MiB clears the selection and shows
File exceeds 1 MiB. Choose a smaller file..
Disable JavaScript and submit that file again: the receiver should reject it with HTTP 413.
Use Back and select a smaller file to recover. A selected zero-byte file succeeds with bytes: 0.
The receiver also rejects missing, repeated, or differently named file fields with HTTP 400, malformed multipart bodies with HTTP 400, and requests over its 2 MiB buffer limit with HTTP 413. HTTP 403 means the request’s host or origin did not match the printed URL. Keep both the form and receiver on that URL.
This local receiver accepts any file type and discards every upload. It does not verify that a
file is an image, a document, or safe to process. An
accept attribute
can guide the picker but cannot validate content. Before connecting this form to a public service,
use that service’s authenticated upload endpoint and its server-side size and content checks.
Keep the input’s field name and the receiver’s expected field in agreement.
