Creating a custom file upload UI with JavaScript
Keep the native file input and add the parts it does not provide: image previews, a removable selection list, and feedback when an image cannot be read. This example lets you replace or clear a selection while previews are still loading, without an older result changing the new list. Files stay in the browser; this page does not submit them.
Decide what your file upload UI owns
A native input already supports multiple file selection.
We will keep it visible for keyboard access and build a preview list around its File objects.
Choosing or dropping files replaces the previous selection.
Remove deletes one row, and
Clear selection deletes them all. Canceling the picker leaves
the list alone.
The demo accepts up to 10 nonempty JPEG, PNG, or GIF images at once, each no larger than 2 MiB.
It checks the declared MIME type, a short signature, and whether the browser can decode a preview.
These are selection feedback, not proof of safe content. The
accept attribute is only a picker hint.
A small compressed file can still decode into a large image; the byte limit does not bound decoded
image memory.
You need a browser, a POSIX shell such as Bash, and Node.js 24.15 or newer in the 24.x line, or
26.5 or newer in the 26.x line. No dependencies or build step are needed. Keep the server’s .mts
extension: Node treats it as an ES module
even inside a CommonJS project.
From the directory where you keep experiments, create a new folder:
mkdir file-preview-demo
If that directory already exists, stop and choose a new name rather than overwriting its files.
Save the next four examples inside file-preview-demo with the filenames shown.
Setting up the HTML structure
Save as index.html. The labeled native input is the keyboard alternative to dropping files.
The list keeps per-file feedback, and the polite status region reports the current selection.
<!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>
Styling the file input with CSS
Save as styles.css. The input’s picker button can be styled without hiding the input. System
colors follow the browser’s light or dark theme, and long filenames wrap within each row.
: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%; }
Implementing JavaScript for enhanced functionality
Save as script.js. Each row owns its reader and object URL. Disposing a row marks it inactive
before aborting a pending read and releasing its URL. Both asynchronous stages check that flag
before changing visible state, so a late signature read or image decode cannot revive a removed row.
FileReader.abort()
cancels a pending read; handling its abort event also settles our promise. Image decoding has no
matching cancellation method here, so we discard its result if the row has been disposed.
img.decode()
lets us show the thumbnail only after decoding succeeds.
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)
Filenames go through textContent, so a name containing HTML is displayed as text.
Keep object URLs alive while their images are available.
Remove, Clear selection,
replacement, decode failure, and leaving the page release them.
Reloading or leaving the page forgets the selection.
The four-byte check recognizes only a prefix. Even a successful browser decode may tolerate a damaged file; neither step proves the image is intact or safe to store. Rejected rows stay visible with their explanation and can still be removed.
Serve the preview page locally
Save as server.mts. This serves only the three browser files and has no upload endpoint. It
selects an available loopback port by default; optionally set PORT if you need a fixed one.
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
})
From the parent directory where you created file-preview-demo, run:
(cd file-preview-demo && node server.mts)
Open the printed URL in your browser. Keep the terminal open, and stop the server with Ctrl+C when
finished; your shell stays in the parent directory. Restart after editing a browser file because
the server reads those files at startup. An occupied port, invalid PORT, or missing file stops
startup with a nonzero status instead of printing a ready URL.
Browser compatibility
The walkthrough was tested on Linux with Node.js 24.15.0, 26.5.0, and 26.8.1, and Chromium 145
and 152. It uses native FileReader, object URLs, drag-and-drop, and img.decode(); the picker
remains the alternative on devices where dropping files is unavailable.
Try the complete selection workflow:
- Tab to Choose images, open the picker, and select two small images. Each row should show its filename, a thumbnail, and Preview ready. The status should report 2 previews ready, 0 checking, 0 rejected. Nothing has been uploaded.
- Tab to a row’s Remove button and activate it. Focus moves to the next row’s Remove button, then the previous one if there is no next row, or the file input if the list is empty. Clear selection also returns focus to the file input.
- Choose or drop a different selection while previews are loading. The earlier rows disappear and cannot return when their reads finish. Choose the same files again to confirm reselection; canceling the picker should keep the current list.
- Try an empty image, an unsupported type, a file larger than 2 MiB, and a damaged image with a recognizable prefix. Their rows should show explanations. Choosing more than 10 files rejects that whole selection.
Connect selection to an uploader
This list owns its File objects and resets the native input after every choice. Submitting that
input as a form would therefore send no selected files. To add delivery, send the retained,
accepted files and coordinate removal or replacement with pending requests.
For a complete sending and receiving example, continue with the custom JavaScript uploader. It handles one JPEG, PNG, or PDF per request with a checksum receipt; its receiver is not a multi-image endpoint and does not accept GIF. Treat it as a separate walkthrough, not a drop-in receiver for this preview list. Any public upload service needs its own server-side validation and authorization; this local UI supplies neither server acceptance nor CSRF protection.
