HTMLmediumforms
Make file uploads accessible
rule · file-upload-accessibility
Accessible file uploads require proper labeling, clear instructions, and feedback that works for all users.
Code Example
HTML
<div class="file-upload">
<label for="document-upload">
Upload document
<span class="file-upload__hint">
Accepted formats: PDF, DOC, DOCX. Max size: 10MB.
</span>
</label>
<input
type="file"
id="document-upload"
name="document"
accept=".pdf,.doc,.docx,application/pdf,application/msword"
aria-describedby="document-help document-error"
/>
<span id="document-help" class="file-upload__help">
Select a file from your computer
</span>
<span id="document-error" class="file-upload__error" role="alert" hidden>
<!-- Error message appears here -->
</span>
</div>Why It Matters
Unlabeled file inputs and inaccessible drag zones exclude screen reader and keyboard users from completing file upload tasks.
Accessibility Requirements
| Requirement | Implementation |
|---|---|
| Label association | <label> linked to input |
| File restrictions | accept attribute + visible text |
| Size limits | Clear messaging |
| Progress feedback | ARIA live regions |
| Error messages | Associated with input |
React File Upload Component
TSX
import { useState, useRef, useId, ChangeEvent, DragEvent } from 'react'
interface FileUploadProps {
label: string
accept?: string
maxSize?: number // in bytes
multiple?: boolean
onUpload: (files: File[]) => Promise<void>
hint?: string
}
export function FileUpload({
label,
accept,
maxSize = 10 * 1024 * 1024, // 10MB default
multiple = false,
onUpload,
hint
}: FileUploadProps) {
const [files, setFiles] = useState<File[]>([])
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [isDragOver, setIsDragOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const inputId = useId()
const errorId = useId()
const helpId = useId()
const progressId = useId()
const validateFile = (file: File): string | null => {
if (maxSize && file.size > maxSize) {
return `File "${file.name}" exceeds maximum size of ${formatBytes(maxSize)}`
}
if (accept) {
const acceptedTypes = accept.split(',').map(t => t.trim())
const isValid = acceptedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type)
}
return file.type === type || file.type.startsWith(type.replace('*', ''))
})
if (!isValid) {
return `File "${file.name}" is not an accepted file type`
}
}
return null
}
const handleFiles = async (fileList: FileList | null) => {
if (!fileList) return
setError(null)
const newFiles = Array.from(fileList)
// Validate all files
for (const file of newFiles) {
const validationError = validateFile(file)
if (validationError) {
setError(validationError)
return
}
}
setFiles(newFiles)
setUploading(true)
setProgress(0)
try {
await onUpload(newFiles)
setProgress(100)
} catch (err) {
setError('Upload failed. Please try again.')
} finally {
setUploading(false)
}
}
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files)
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
setIsDragOver(false)
handleFiles(e.dataTransfer.files)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = () => {
setIsDragOver(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
inputRef.current?.click()
}
}
return (
<div className="file-upload">
<label htmlFor={inputId} className="file-upload__label">
{label}
{hint && (
<span className="file-upload__hint">{hint}</span>
)}
</label>
{/* Drag and drop zone */}
<div
className={`file-upload__dropzone ${isDragOver ? 'file-upload__dropzone--active' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-describedby={`${helpId} ${error ? errorId : ''}`}
>
<input
ref={inputRef}
type="file"
id={inputId}
accept={accept}
multiple={multiple}
onChange={handleChange}
aria-invalid={!!error}
aria-describedby={`${helpId} ${error ? errorId : ''}`}
className="file-upload__input"
/>
<div className="file-upload__content">
<span className="file-upload__icon" aria-hidden="true">📁</span>
<span>
Drag and drop files here, or{' '}
<span className="file-upload__browse">browse</span>
</span>
</div>
</div>
<span id={helpId} className="file-upload__help">
{accept && `Accepted formats: ${accept}`}
{maxSize && ` Max size: ${formatBytes(maxSize)}`}
</span>
{/* Error message */}
{error && (
<span id={errorId} className="file-upload__error" role="alert">
{error}
</span>
)}
{/* Progress */}
{uploading && (
<div
id={progressId}
className="file-upload__progress"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Upload progress"
>
<div
className="file-upload__progress-bar"
style={{ width: `${progress}%` }}
/>
<span className="sr-only">{progress}% uploaded</span>
</div>
)}
{/* File list */}
{files.length > 0 && !uploading && (
<ul className="file-upload__files" aria-label="Selected files">
{files.map((file, index) => (
<li key={index}>
{file.name} ({formatBytes(file.size)})
</li>
))}
</ul>
)}
{/* Live region for announcements */}
<div aria-live="polite" className="sr-only">
{uploading && `Uploading ${files.length} file(s)... ${progress}% complete`}
{progress === 100 && 'Upload complete'}
</div>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}Usage
TSX
<FileUpload
label="Upload resume"
accept=".pdf,.doc,.docx"
maxSize={5 * 1024 * 1024}
hint="PDF or Word document, max 5MB"
onUpload={async (files) => {
const formData = new FormData()
files.forEach(file => formData.append('files', file))
await fetch('/api/upload', { method: 'POST', body: formData })
}}
/>Custom Styled File Input
TSX
function CustomFileInput({ label, ...props }: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const inputId = useId()
return (
<div className="custom-file-input">
<input
ref={inputRef}
type="file"
id={inputId}
className="sr-only"
{...props}
/>
<label htmlFor={inputId} className="custom-file-input__label">
{label}
</label>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="custom-file-input__button"
>
Choose File
</button>
</div>
)
}Styling
CSS
.file-upload__dropzone {
padding: 2rem;
border: 2px dashed #ccc;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.file-upload__dropzone:hover,
.file-upload__dropzone:focus-visible {
border-color: #0066cc;
background: #f0f7ff;
}
.file-upload__dropzone--active {
border-color: #0066cc;
background: #e3f2fd;
}
.file-upload__dropzone:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
.file-upload__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.file-upload__browse {
color: #0066cc;
text-decoration: underline;
}
.file-upload__hint {
display: block;
font-size: 0.875rem;
color: #666;
margin-top: 0.25rem;
}
.file-upload__help {
display: block;
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.file-upload__error {
display: block;
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.file-upload__progress {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-top: 1rem;
}
.file-upload__progress-bar {
height: 100%;
background: #0066cc;
transition: width 0.2s;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}Verification
- Navigate to file input with keyboard
- Press Enter/Space to open file picker
- Verify label is announced by screen reader
- Test drag and drop with mouse
- Verify error messages are announced
- Check progress updates are announced
- Test with invalid file types and oversized files