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 supports keyboard navigation, ARIA attributes, and screen readers, making it fully accessible.
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 {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.setAttribute('role', 'button'); // Make the host element announce itself as a button
}
connectedCallback() {
this.setAttribute('tabindex', '0'); // Make the host element focusable
this.render();
this.addEventListeners();
}
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;
cursor: pointer;
background-color: #f9f9f9;
}
:host(:focus) {
outline: 2px solid blue;
border-color: blue; /* Visual feedback on focus */
}
:host([aria-disabled="true"]) {
cursor: not-allowed;
opacity: 0.6;
}
label {
/* Ensure label is clickable and covers the area */
display: block;
width: 100%;
height: 100%;
}
.file-info {
margin-top: 10px;
font-size: 0.9em;
color: #333;
}
</style>
<input type="file" id="fileInput" hidden />
<label for="fileInput" id="labelForFileInput">Drag files here or click to upload</label>
<div class="file-info" id="fileInfo"></div>
`;
}
addEventListeners() {
const fileInput = this.shadowRoot.querySelector('#fileInput');
const fileInfo = this.shadowRoot.querySelector('#fileInfo');
// Click on host triggers file input
this.addEventListener('click', () => fileInput.click());
this.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // Prevent space from scrolling page
fileInput.click();
}
});
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (file) {
// Example validation (can be expanded)
if (file.size > 10 * 1024 * 1024) { // 10MB limit
fileInfo.textContent = 'File too large (max 10MB). Please select another file.';
fileInput.value = ''; // Clear the invalid selection
return;
}
// Example type validation (uncomment to use)
// if (!['image/jpeg', 'image/png'].includes(file.type)) {
// fileInfo.textContent = 'Invalid file type. Please select JPG or PNG.';
// fileInput.value = ''; // Clear the invalid selection
// return;
// }
fileInfo.textContent = \`Selected file: \${file.name} (Type: \${file.type})\`;
// 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
}));
} else {
fileInfo.textContent = '';
}
});
}
}
customElements.define('file-upload-widget', FileUploadWidget);
In the code above, we've consolidated the component's structure. The constructor
sets up the
Shadow DOM and assigns role="button"
to the host element for better accessibility. The
connectedCallback
makes the element focusable, calls render
to inject HTML and CSS into the
Shadow DOM, and then calls addEventListeners
to set up interactions.
Implementing keyboard navigation and focus management
Keyboard navigation is crucial for accessibility. Our FileUploadWidget
is made focusable by
setting tabindex="0"
on the host element in connectedCallback
. We also added an event listener
for keydown
events in addEventListeners
. If the 'Enter' or 'Space' key is pressed while the
widget is focused, it programmatically clicks the hidden file input, triggering the file selection
dialog. This behavior mimics a native button element.
Adding aria attributes for screen reader support
To enhance screen reader support, we've added role="button"
to the host element
(<file-upload-widget>
) itself in the constructor
. This informs assistive technologies that the
custom element behaves like a button. The text content of the <label>
("Drag files here or click
to upload") within the Shadow DOM will serve as the accessible name for this button. We removed the
redundant aria-label
from the inner <label>
as the for
attribute already links it to the
hidden input, and the host's role and its content provide sufficient context.
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 and type.
- A basic example of file size validation (e.g., checking if the file is larger than 10MB). If validation fails, an error message is shown, and the file input is cleared.
- A commented-out example for file type validation.
- Dispatching a
CustomEvent
namedfile-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 selectedfile
object is passed in the event'sdetail
property.
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's internal style is:
:host {
--border-color: #ccc; /* This line is not strictly needed if using the var() default */
border: 2px dashed var(--border-color, #ccc); /* Default value provided here */
}
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>
<input type="file" />
</noscript>
This provides a standard file input if the Web Component cannot be initialized.
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 its role (button) and accessible name (e.g., "Drag files here or click to upload, button").
- File selection and any status messages are announced appropriately.
Framework integration examples
Web Components are designed to integrate seamlessly with popular frameworks:
- React: Use the widget directly as a custom element. Note that React passes all data to Custom
Elements using HTML attributes. For complex data like objects or arrays, you might need to
serialize them to strings or handle them via direct DOM manipulation with refs. Also, use
class
instead ofclassName
for CSS classes on custom elements. To listen to custom events, you'll need to use a ref andaddEventListener
.// function MyReactApp() { // const widgetRef = React.useRef(null); // React.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: Use the widget directly. You'll need to inform Vue that this is a custom element to
prevent warnings, typically by configuring
app.config.compilerOptions.isCustomElement
in yourmain.js
(e.g.,tag => tag.includes('-')
) or by naming Single-File Components intended for custom element usage with a.ce.vue
extension if you are building custom elements with Vue itself.// In main.js (Vue 3) // 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_SCHEMA
in theschemas
array of your relevantNgModule
to 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.