Build an accessible file upload widget with web components
Web Components offer a powerful way to create reusable, encapsulated UI elements that work seamlessly across different frameworks. In this DevTip, we'll explore how to build an accessible file upload widget using Web Components, Shadow DOM, and modern JavaScript. We'll ensure our widget uses native keyboard controls and a live status region. It selects a file and emits an event; the host application is responsible for uploading it and reporting upload progress.
Why web components for file upload widgets?
Web Components are a set of web platform APIs that allow you to create custom, reusable HTML elements. They encapsulate functionality and styling, making them ideal for creating consistent UI components across various projects and frameworks. By leveraging Shadow DOM, we can isolate our widget's styles and structure, preventing conflicts with other parts of the application.
Setting up the web component structure
Let's define our custom element, FileUploadWidget. This class will encapsulate all the logic and
presentation for our widget. We'll set up the Shadow DOM, render the initial HTML structure, and
attach necessary event listeners.
class FileUploadWidget extends HTMLElement {
static observedAttributes = ['disabled'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
this.addEventListeners();
}
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); /* Added default for --border-color */
padding: 20px;
text-align: center;
background-color: #f9f9f9;
}
button:focus-visible {
outline: 2px solid blue;
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;
color: #333;
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')) return;
const file = fileInput.files[0];
if (file) {
// Example validation (can be expanded)
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}`;
// Dispatch an event for parent components
this.dispatchEvent(new CustomEvent('file-selected', {
detail: { file },
bubbles: true, // Allows event to bubble up through the DOM
composed: true // Allows event to cross shadow DOM boundaries
}));
fileInput.value = ''; // Allow the same file to be selected again.
} else {
fileInfo.textContent = '';
}
});
}
}
customElements.define('file-upload-widget', FileUploadWidget);
Include the script once, then add <file-upload-widget></file-upload-widget> to your page. The
constructor creates the Shadow DOM and its listeners once, without mutating host attributes.
Reconnecting the same element preserves its status and does not duplicate listeners. The observed
disabled attribute updates the native controls, including when it changes after connection.
Implementing keyboard navigation and focus management
The native button receives Tab focus and handles Enter and Space without a custom keyboard handler.
Its click opens the file chooser during the user's activation. The visible focus outline is kept,
and adding disabled to the custom element disables both the button and the file input.
Adding ARIA attributes for screen reader support
The button's visible text supplies its accessible name. aria-describedby associates the size
limit with that button. The separate role="status" region announces selection and validation
feedback politely; it is not nested inside a button role, which could hide its semantics.
Handling file selection and validation
The addEventListeners method includes a change event listener on the hidden
<input type="file">. When a file is selected, this listener activates. The updated code includes:
- Displaying the selected file's name as text, not HTML.
- A basic example of file size validation (checking if the file is larger than 10 MiB). If validation fails, an error message is shown, and the file input is cleared.
- Dispatching a
CustomEventnamedfile-selected. This event bubbles up through the DOM and can cross Shadow DOM boundaries (composed: true), allowing parent components or other JavaScript code to react to the file selection. The selectedfileobject is passed in the event'sdetailproperty.
Client-side validation is feedback, not a security boundary. The upload server must independently validate size and content, enforce authorization and storage quotas, and reject unsafe files. This example intentionally offers file selection, not drag and drop.
Custom styling with CSS custom properties
To allow users to customize the widget's appearance, CSS custom properties are a great solution. In
our component's <style> block, we use var(--border-color, #ccc). This means the border will use
the --border-color variable if it's defined by the user; otherwise, it defaults to #ccc.
/* Example of how a user might set the custom property */
file-upload-widget {
--border-color: #007bff;
}
The component uses the fallback directly in its border declaration:
:host {
border: 2px dashed var(--border-color, #ccc);
}
The var(--border-color, #ccc) syntax directly in the border property is the standard way to
provide a fallback.
Progressive enhancement and fallback strategies
It's important to ensure your widget degrades gracefully if JavaScript is disabled. The <noscript>
tag provides a basic fallback.
<noscript>
<p>
JavaScript is required for the enhanced file upload widget. Please enable JavaScript or use the
basic file input below:
</p>
<label for="basic-file">Choose a file</label>
<input type="file" id="basic-file" name="file" />
</noscript>
This fallback covers disabled JavaScript, not a script download or initialization failure. If file selection must survive those failures too, render a labeled native input in the initial HTML and enhance it only after the component initializes successfully. A file input alone does not upload; connect the fallback to your application's form and upload endpoint.
Testing accessibility with screen readers
Always test your widget with screen readers like NVDA (Windows), JAWS (Windows), or VoiceOver (macOS) to ensure it's fully accessible. Verify that:
- The widget is focusable using the Tab key.
- It can be activated using 'Enter' or 'Space'.
- The screen reader announces the button's name, role and size-limit description.
- File selection and any status messages are announced appropriately.
- Disabling, re-enabling, removing and reconnecting the widget does not duplicate interactions.
Framework integration examples
Web Components are designed to integrate seamlessly with popular frameworks:
- React: In React 19, custom elements support properties and custom event handlers.
The ref-based event listener below also works in older React versions. Register the custom element
before rendering it.
import { useEffect, useRef } from 'react'; export function MyReactApp() { const widgetRef = useRef(null); useEffect(() => { const node = widgetRef.current; const handleFileSelected = (event) => console.log('File selected:', event.detail.file); node?.addEventListener('file-selected', handleFileSelected); return () => node?.removeEventListener('file-selected', handleFileSelected); }, []); return <file-upload-widget ref={widgetRef}></file-upload-widget>; } - Vue: Register the widget before mounting the app. Tell Vue to treat
file-upload-widgetas a custom element usingcompilerOptions.isCustomElement. For compiled Single-File Components, set this option in your Vue build configuration. The following runtime configuration works only when Vue compiles templates in the browser:// In-browser template compilation only, before app.mount() app.config.compilerOptions.isCustomElement = tag => tag === 'file-upload-widget';<template><file-upload-widget @file-selected="onFileSelected"></file-upload-widget></template> <script setup> const onFileSelected = (event) => { console.log('File selected in Vue:', event.detail.file); }; </script> - Angular: Include
CUSTOM_ELEMENTS_SCHEMAin theschemasarray of your relevantNgModuleto allow the use of custom tags without Angular throwing errors. You can then use the custom element in your templates and listen to its events.// In your Angular module (e.g., app.module.ts) import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; @NgModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class AppModule { }<!-- In your Angular component template --> <file-upload-widget (file-selected)="onFileSelected($event)"></file-upload-widget>
Conclusion
Building an accessible file upload widget with Web Components ensures reusability, encapsulation, and accessibility across your projects. This approach provides a solid foundation for creating robust and user-friendly file input experiences. For more robust file handling, consider integrating with Transloadit's /upload/handle Robot.
