A support ticket form needs an attachment field. The mockup shows a large box with a dashed border and the words “Drop your files here”. One way to build it is to hide the <input type="file"> with display: none and open the picker when someone clicks the surrounding <div>. That removes the input from keyboard navigation and the accessibility tree, and the clickable <div> doesn't give keyboard users a way in.
I kept the native file input. VoiceOver didn't read my status message at first, and Firefox sent paste events somewhere I wasn't listening. You can try the finished field before following the steps.

Building the field, step by step #
The examples below keep the styling simple. The demo adds the layout and colours shown above.
Step 1: An input and a label #
<label for="attachments">Attachments</label>
<input type="file" id="attachments" name="attachments">
This is already a working field. It has an accessible name, takes focus, opens the picker from the keyboard and shows which file was chosen. A form with method="post" and enctype="multipart/form-data" can send it. None of this needs JavaScript.
Step 2: File requirements #
<label for="attachments">Attachments</label>
<p id="attachments-hint">PNG or JPEG, up to 2 MB each.</p>
<input type="file" id="attachments" name="attachments"
accept="image/png, image/jpeg" required
aria-describedby="attachments-hint">
The accept attribute filters the picker, and required blocks an empty submission. The requirements are written next to the field, and aria-describedby connects them to it, so people learn them before opening the picker.
This filter only guides the selection. Some file pickers let you switch to “All files”, and neither the extension nor the MIME type proves that a file is an image, so the server has to check. For now, the 2 MB limit is only text in the hint.
Step 3: Styling #
The input's own button can be styled with ::file-selector-button:
input[type="file"]::file-selector-button {
border: 0;
border-radius: 0.25rem;
padding: 0.5rem 1rem;
margin-inline-end: 0.75rem;
background: canvastext;
color: canvas;
font: inherit;
cursor: pointer;
}
If that's enough for your design, stop here. The browser still draws the file name or “No file chosen” next to the button, and there's no pseudo-element for that text.
For a fully custom look, the <label> can act as the button. The input stays in the HTML:
<p id="attachments-hint">PNG or JPEG, up to 2 MB each.</p>
<input type="file" id="attachments" name="attachments"
accept="image/png, image/jpeg" required
aria-describedby="attachments-hint">
<label for="attachments" class="button">Choose attachments</label>
.visually-hidden {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.button {
display: inline-block;
border: 1px solid;
border-radius: 0.25rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
/* Keep focus on the input; draw the outline around the label. */
input[type="file"]:focus-visible + label {
outline: 2px solid;
outline-offset: 2px;
}
The input comes before the label so that + can draw the focus ring on the label. Clicking the label opens the picker; keyboard focus stays on the real input.
The visually-hidden class keeps the input in the tab order and the accessibility tree, but hides the native control, including its file name. I'll add the class from JavaScript in the next step, once the page can show the selection itself.
Step 4: Showing the selection #
Add a status message after the label, and add its id to the input's aria-describedby:
<input type="file" id="attachments" name="attachments"
accept="image/png, image/jpeg" required
aria-describedby="attachments-hint attachments-status">
<label for="attachments" class="button">Choose attachments</label>
<output id="attachments-status"></output>
<output> has an implicit status role, so screen readers should announce its updates without moving focus.
In Safari 27, VoiceOver read the field after the picker closed, but my status message was missing from its captions. Adding the status to aria-describedby made it read the message as part of the field's description.
Place the script after the markup or load it with defer:
const input = document.querySelector('#attachments');
const status = document.querySelector('#attachments-status');
const format = bytes => bytes < 1024 * 1024
? Math.ceil(bytes / 1024) + ' KB'
: (bytes / 1024 / 1024).toFixed(1) + ' MB';
input.addEventListener('change', () => {
const [file] = input.files;
status.textContent = file
? `Selected ${file.name}, ${format(file.size)}`
: 'No files selected.';
});
Now the script can show the selected file, so add this line at the end, after the handlers:
input.classList.add('visually-hidden');
Leave the class out of the HTML, as above. If JavaScript doesn't run, the native input will still show the file name. Keep this line at the end as you add the handlers in the following steps. The demo does the same.
Step 5: Checking the size #
HTML has no attribute for a file-size limit. I'll check the size in the change handler and use setCustomValidity() to tell the browser when the file is too large. Replace the previous handler:
const MAX_SIZE = 2 * 1024 * 1024;
input.addEventListener('change', () => {
const [file] = input.files;
const oversized = file && file.size > MAX_SIZE;
input.setCustomValidity(oversized ? 'That file is larger than 2 MB.' : '');
status.textContent = oversized
? 'That file is larger than 2 MB. Please choose a smaller one.'
: file ? `Selected ${file.name}, ${format(file.size)}` : 'No files selected.';
});
A non-empty string makes the field invalid and blocks submission. Passing an empty string clears the error when the replacement file is within the limit. The browser shows its message when someone submits the form, while <output> shows the error straight away. The server still needs its own check.
Step 6: Multiple files #
Add multiple to the input and a list after the status message:
<input type="file" id="attachments" name="attachments"
accept="image/png, image/jpeg" multiple required
aria-describedby="attachments-hint attachments-status">
<label for="attachments" class="button">Choose attachments</label>
<output id="attachments-status"></output>
<ul id="attachments-list"></ul>
With multiple, the form sends one attachments entry per file. Some back ends, PHP for example, expect the name attachments[] to collect them into a list.
Replace the change handler from step 5 with a render() function. It lists each file with a remove button, checks the sizes and updates the status. Calling it once on load fills in “No files selected.” straight away:
const list = document.querySelector('#attachments-list');
function render() {
const files = [...input.files];
const oversized = files.filter(file => file.size > MAX_SIZE);
list.textContent = '';
files.forEach((file, index) => {
const item = document.createElement('li');
const name = document.createElement('span');
const remove = document.createElement('button');
name.textContent = `${file.name} — ${format(file.size)} `;
remove.type = 'button';
remove.textContent = 'Remove';
remove.setAttribute('aria-label', `Remove ${file.name}`);
remove.addEventListener('click', () => removeFile(index));
item.append(name, remove);
list.append(item);
});
const names = oversized.map(file => file.name).join(', ');
const message = oversized.length
? `Files over 2 MB: ${names}. Please choose smaller files.`
: '';
input.setCustomValidity(message);
status.textContent = message || (files.length
? `${files.length} selected: ${files.map(file => file.name).join(', ')}`
: 'No files selected.');
}
input.addEventListener('change', render);
render();
FileList has no method for removing a file, so add a removeFile() function to the same script. It builds a new list with DataTransfer:
function removeFile(index) {
const next = new DataTransfer();
[...input.files].forEach((file, i) => {
if (i !== index) next.items.add(file);
});
input.files = next.files;
render();
input.focus();
}
Assigning to input.files means the form sends exactly the files shown in the list. The assignment doesn't fire change, so removeFile() calls render() itself. Rebuilding the list also destroys the Remove button that had focus, so focus goes back to the input.
Step 7: Drag and drop #
Wrap the field in <div class="dropzone">. Here's the whole form at this point, including the two attributes a form needs to send files:
<form action="/tickets/42/attachments" method="post" enctype="multipart/form-data">
<div class="dropzone">
<p id="attachments-hint">
PNG or JPEG, up to 2 MB each. You can also drop files here.
</p>
<input type="file" id="attachments" name="attachments"
accept="image/png, image/jpeg" multiple required
aria-describedby="attachments-hint attachments-status">
<label for="attachments" class="button">Choose attachments</label>
<output id="attachments-status"></output>
<ul id="attachments-list"></ul>
</div>
<button type="submit">Send</button>
</form>
/tickets/42/attachments stands in for your server endpoint. Then handle two events on the wrapper:
const zone = document.querySelector('.dropzone');
zone.addEventListener('dragover', event => {
event.preventDefault();
});
zone.addEventListener('drop', event => {
event.preventDefault();
if (event.dataTransfer.files.length) {
const hadFocus = list.contains(document.activeElement);
input.files = event.dataTransfer.files;
render();
if (hadFocus) input.focus();
}
});
Cancelling dragover lets the zone accept a drop, and cancelling drop stops the browser from opening the file. That only covers the zone: a file dropped just outside it may open in the browser, unless you cancel the same two events on document.
If a Remove button still has focus when a file is dropped, render() removes it. I return focus to the input in that case, as removeFile() does.
Dropped files replace the current selection and skip the accept filter. The file picker stays available to anyone who doesn't drag.
To keep a dropped PDF from being sent as an attachment, check the type in render() next to the size. Add the allowed types and a helper for file names at the top of the script:
const TYPES = ['image/png', 'image/jpeg'];
const names = files => files.map(file => file.name).join(', ');
Then, in render(), replace the names and message constants from step 6:
const unsupported = files.filter(file => !TYPES.includes(file.type));
const message = [
unsupported.length ? `Not PNG or JPEG: ${names(unsupported)}. Please choose PNG or JPEG files.` : '',
oversized.length ? `Files over 2 MB: ${names(oversized)}. Please choose smaller files.` : '',
].filter(Boolean).join(' ');
This also catches a file chosen after switching the picker to “All files”. The browser usually guesses file.type from the extension, and it can be empty. This check rejects a file with an empty type too, even one with a .png extension. The server still has to check the file's contents.
The demo also highlights the zone during a drag. dragenter and dragleave fire for every child element the pointer crosses, so it counts them instead of removing the highlight on the first dragleave.
Step 8: Pasting a screenshot #
For a support ticket, pasting a screenshot is handy. I first listened for paste on the drop zone, but the handler did nothing in Firefox 156. Logging the event showed that Firefox sent it to the element containing the page's text selection instead of the focused file input.
Chrome 154 and Safari 27.0 sent the event to the input in the same test. Chrome used the element containing the selected text too if I left text highlighted before tabbing to the field.
So I moved the listener to document and checked which element had focus:
document.addEventListener('paste', event => {
if (document.activeElement === input && event.clipboardData.files.length) {
event.preventDefault();
input.files = event.clipboardData.files;
render();
}
});
The handler only acts when the file input has focus and the clipboard holds files, so pasting text elsewhere on the page still works. Like a drop, a paste replaces the current selection.
Add it to the hint so people know it's there:
<p id="attachments-hint">
PNG or JPEG, up to 2 MB each. You can also drop files here.
To paste a screenshot, Tab to “Choose attachments”, then paste.
</p>
A file input isn't an editable context. In that case, the Clipboard API spec says the event should go to the focused node.
I reported the Firefox behaviour as bug 2074987. A Mozilla engineer replied that Chrome's approach, using the focused element when the selection isn't visible, seems reasonable. Listening on document and checking document.activeElement worked in all three browsers in my tests.
Try the field #
Here's the finished field. Choose files, remove them, try a file over 2 MB, paste a screenshot or submit the form empty. The demo shows what the form would send without uploading any files. Use Reset to start again.
Open the demo on its own to test it with a keyboard or a screen reader without an iframe.
I tested it with the keyboard in Chrome 154 and with VoiceOver in Safari 27 on macOS 27. In this recording, VoiceOver's captions show what it says as I pick a file that's too large, submit the form, remove the file, pick a smaller one and submit again. The recording uses an earlier version of the hint.
With one valid file selected, VoiceOver read “Choose attachments PNG or JPEG, up to 2 MB each. … 1 selected: small-screenshot.png required file upload button”.
On the empty field, VoiceOver says “required invalid” before anything has happened: an empty required field is invalid. Once I select a valid file, VoiceOver stops saying “invalid”. Pasting works with VoiceOver too: it says “Choose attachments: small-screenshot.png”, then the new status.
The rough edge is the browser's validation message. In Chrome, it points at the 1×1 px input in the label's top-left corner and covers part of the label:

In Safari, the same message is cut off by the left edge of the window. VoiceOver still reads it in full, as “In popover Files over 2 MB: large-photo.jpg. Please choose smaller files.”, and the size error also stays on the page below the button. For an empty field, though, the browser's message is all there is.
I haven't tried NVDA, JAWS or the mobile screen readers yet. If you do, I'd like to hear what you get.
Start with the native input #
I'd start with the native input from step 3. If styling its button is enough, the browser can handle the file name, keyboard access and validation without any JavaScript.
For a custom field, I'd build around the same input. In this example, it still opens the picker, takes keyboard focus and holds the files for submission. The extra code adds the file list, drag and drop, and screenshot paste.
Further reading #
<input type="file">::file-selector-button- Advanced form styling
- Forms are a badly designed part of HTML by Jens Grochtdreis
- The search input: They almost got it right by Steve Frenzel