Build an accessible file upload widget with web components
Use a native button and file input inside a custom element to select one file and pass its
File object to your application. This file upload widget keeps its styles in Shadow DOM,
checks a 10 MiB size limit, and exposes selection feedback through a status region. The host
application handles uploading; this example makes no network requests.
Why web components for file upload widgets?
Choose a custom element when several applications need the same selection control without sharing a UI framework. Shadow DOM keeps the internal styles separate, while a DOM event gives the host a small interface. If you only need a styled input in one form, a native file input is simpler.
Setting up the web component structure
You need a browser with Custom Elements and Shadow DOM, a text editor, and a local test file. The plain HTML demo needs no package manager, server, or build step. Its keyboard and file-picker behavior was tested in Chromium 145 on Linux; test the browser and assistive technology pairings used by your audience before shipping.
Create a new directory in your editor. Save the following complete component as
file-upload-widget.js inside it:
class FileUploadWidget extends HTMLElement {
static observedAttributes = ['disabled'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
this.addEventListeners();
this.ready = true;
}
connectedCallback() {
this.syncDisabled();
}
attributeChangedCallback() {
this.syncDisabled();
}
syncDisabled() {
const disabled = this.hasAttribute('disabled');
this.shadowRoot.querySelector('button').disabled = disabled;
this.shadowRoot.querySelector('input').disabled = disabled;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 2px dashed var(--border-color, #ccc);
padding: 20px;
text-align: center;
color-scheme: light dark;
background-color: Canvas;
color: CanvasText;
}
:host([hidden]) { display: none; }
button:focus-visible {
outline: 2px solid Highlight;
outline-offset: 4px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
button {
max-width: 100%;
white-space: normal;
padding: 12px;
}
.file-info {
margin-top: 10px;
font-size: 0.9em;
overflow-wrap: anywhere;
}
</style>
<input type="file" id="fileInput" hidden />
<button type="button" aria-describedby="fileHint">Choose a file</button>
<p id="fileHint">Maximum file size: 10 MiB.</p>
<p class="file-info" id="fileInfo" role="status" aria-atomic="true"></p>
`;
}
addEventListeners() {
const fileInput = this.shadowRoot.querySelector('#fileInput');
const fileInfo = this.shadowRoot.querySelector('#fileInfo');
this.shadowRoot.querySelector('button').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (this.hasAttribute('disabled')) {
fileInput.value = '';
return;
}
const file = fileInput.files[0];
if (file) {
if (file.size > 10 * 1024 * 1024) {
fileInfo.textContent = 'File too large (max 10 MiB). Please select another file.';
fileInput.value = ''; // Clear the invalid selection
return;
}
fileInfo.textContent = `Selected file: ${file.name}`;
this.dispatchEvent(new CustomEvent('file-selected', {
detail: { file },
bubbles: true,
composed: true
}));
fileInput.value = ''; // Allow the same file to be selected again.
} else {
fileInfo.textContent = '';
}
});
}
}
customElements.define('file-upload-widget', FileUploadWidget);
The constructor builds the shadow tree and installs listeners once. The connection callback only synchronizes the disabled state, so removing and reconnecting the same element preserves its feedback without adding listeners. The observed attribute also handles later changes; see MDN’s custom-element lifecycle documentation.
Save this page as index.html in the same directory:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web component file selection</title>
<script src="file-upload-widget.js" defer></script>
<script src="demo.js" defer></script>
</head>
<body>
<main>
<h1>Select one file</h1>
<p id="basic-picker">
<label for="basic-file">Choose a file</label>
<input type="file" id="basic-file" name="file" />
</p>
<file-upload-widget id="picker" hidden></file-upload-widget>
<p id="received">No file received by the host.</p>
<noscript><p>The basic input selects a file; this demo does not upload it.</p></noscript>
</main>
</body>
</html>
Save the receiving side as demo.js:
const widget = document.querySelector('#picker');
const basicPicker = document.querySelector('#basic-picker');
const received = document.querySelector('#received');
widget.addEventListener('file-selected', (event) => {
const { file } = event.detail;
window.selectedFile = file;
received.textContent = `Host received: ${file.name} (${file.size} bytes).`;
});
// Keep the basic input available if the component failed to load or initialize.
if (widget.ready === true) {
basicPicker.hidden = true;
widget.hidden = false;
}
Open index.html in the browser. Press Tab to reach Choose a file,
then Enter or Space to open the native picker. Choose a file of at most 10 MiB. The widget shows
Selected file: followed by its name, and the host shows
Host received: followed by the name and byte count.
window.selectedFile holds the actual File for the host to consume.
Implementing keyboard navigation and focus management
The native button handles Enter and Space, so it needs no custom keydown handler or button role. Its click opens the hidden input during user activation. Keep the visible focus outline and check that focus returns to the button after selecting a file or canceling the picker. The WAI button pattern describes these keyboard expectations.
Add the boolean disabled attribute to file-upload-widget to disable both native controls.
Remove the attribute to re-enable them; disabled="false" still disables the element because the
attribute is present. If your application disables or removes the currently focused control,
move focus to a useful remaining control in the host. Reconnecting a node does not restore lost
focus by itself.
Adding ARIA attributes for screen reader support
The button’s visible text supplies its accessible name. aria-describedby="fileHint" connects
the size limit inside the same shadow root. The separate, initially empty
role="status" region has polite live-region semantics;
aria-atomic="true" requests the whole updated message. Updating the text does not move focus
to the status.
These semantics support assistive technology, but visible feedback and an accessibility tree do not prove a spoken announcement. In the tested Linux Orca 46.1/Chromium 145 pairing, automatic selection speech after returning from the native picker was intermittent. Re-selecting the same file also produces identical text, which a screen reader may not announce again. Keep the feedback visible and run the screen-reader checks below for your target pairings.
Handling file selection and validation
The change listener reads the selected File, writes its name as text rather than HTML,
and emits one file-selected event for an accepted selection. event.detail.file is the file,
not its path or a reconstructed copy. The event bubbles and is composed, so a host outside another
shadow root can receive it too.
The limit is 10 × 1,024 × 1,024 bytes. Empty files and files at or below the limit are accepted. A larger file shows File too large (max 10 MiB). Please select another file. and emits no selection event. It leaves the host’s previous file alone. The input is cleared after acceptance, rejection, or an ignored selection while disabled, so choosing the same file after re-enabling can produce another event. Canceling the picker produces no new selection.
Client-side validation supplies feedback; your upload server must independently validate size
and content and enforce authorization and storage quotas. There is no drag and drop, upload
progress, or form submission in this component. Clearing its input also means a surrounding form
will not submit the selected file automatically. Use the event’s File in your host’s upload code.
Custom styling with CSS custom properties
The component’s border uses var(--border-color, #ccc). To override it, put this rule in a
style element in the head of index.html; it reaches the component without selecting into
the shadow tree:
file-upload-widget {
--border-color: #007bff;
}
The demo uses system canvas, text, and focus colors with color-scheme: light dark. Check your
own theme’s contrast and focus indicator when adding styles. Long filenames wrap instead of
forcing the control wider than its container.
Progressive enhancement and fallback strategies
The initial HTML shows a labeled native input and keeps the enhanced widget hidden. Only after
the constructor finishes and the receiving script is ready does demo.js swap them. If
JavaScript is disabled, either script fails to download, or the component fails to initialize,
the basic input remains available. This is broader than a noscript fallback alone.
The basic input only selects a file: it has no size validation, host-event handler, or upload endpoint. For a form that must submit without JavaScript, connect that native input to your actual multipart form and server. Do not hide it until the replacement can perform the task.
Testing accessibility with screen readers
Test with your supported browser and screen-reader pairings, such as NVDA or JAWS on Windows, VoiceOver on macOS, and Orca on Linux. The automated Linux probe here is not a human screen-reader usability study and does not establish universal accessibility. Verify:
- Tab reaches Choose a file once, with a visible focus indicator.
- Enter and Space open the actual file picker. Selection and cancellation return focus to the button.
- The button has a name, button role, and size-limit description. Check what is actually spoken, including whether the description needs the screen reader’s help command.
- A new filename and the oversize-file message are available to the screen reader. Check automatic announcements separately from reading the feedback manually, including repeated selection.
- Disabling and re-enabling the control works. Removing and reconnecting it preserves feedback without duplicating events; the host supplies focus recovery when it removes a focused control.
- With JavaScript disabled and with a broken component script, the basic input still selects a file.
Integrate with a framework
Register file-upload-widget.js once in the browser before mounting your application, then
receive file-selected and read event.detail.file in the host. Keep server rendering and
upload transport outside the component. These are integration points for an existing app, rather
than separate framework project walkthroughs:
- React 19 custom elements support properties and custom event handlers. Preserve the event’s dash and casing when binding a custom event, or install a DOM listener through a ref and remove that same listener when the wrapper unmounts.
- In Vue, mark this tag as a custom element with
compilerOptions.isCustomElementin your Vue build configuration. Settingapp.config.compilerOptionsonly works with in-browser template compilation, not precompiled Single-File Components. A template listener receives the DOM event. - Angular’s
CUSTOM_ELEMENTS_SCHEMAallows dash-named custom elements. Add it to the consuming standalone component’sschemas, or to the declaring NgModule’sschemasfor a module-based app. Bind the DOM event in that component’s template and handle itsFilein the application.
Connect the host to an upload service
Replace the demo’s receiving handler with your application’s upload operation and give upload success or failure its own feedback. Selecting a file is not evidence that a server accepted it. Transloadit’s /upload/handle Robot is further reading for server-side file ingestion; it is not a drop-in endpoint for this local selection demo.
