File inputs
tier 2 — classesA picker that can be styled, and a dropzone that takes a real drop.
File input
<input type="file"> cannot be restyled: its button is a shadow-DOM part with
only ::file-selector-button exposed, and its “No file chosen” text is
not addressable at all. .form-file hides the input behind a label styled as a
button, which keeps the real control — a <label> for a file input opens the
picker on click and on Enter, with no JavaScript and no ARIA.
The input is clipped rather than display: none, so it stays focusable and in the
accessibility tree. The focus ring is drawn on the label, because the focused element is the
one the reader cannot see.
::file-selector-button is styled too, for anyone using the input bare.
<div class="dr-col dr-gap-2" style="max-width:460px">
<div class="form-field">
<label class="form-label" for="ff-styled">Attachment</label>
<label class="form-file">
<input type="file" id="ff-styled" />
<span class="btn"><i class="ri-attachment-2"></i> Choose a file</span>
<span class="form-file-name">No file chosen</span>
</label>
</div>
<div class="form-field">
<label class="form-label" for="ff-bare">The same input, bare</label>
<input class="form-input" id="ff-bare" type="file" />
<span class="form-hint">Chromium lays a file input out around its own button, so the block
padding is the engine's rather than ours — the box height still comes from the control
tier.</span>
</div>
</div>
Dropzone and file list
Live — drag a file onto it. A drop target that is also a click target: drag and drop alone is unusable by keyboard and awkward on touch, so the zone wraps a real file input and the drag handling is an enhancement on top.
data-dropzone hands it to drSimpleUi.dropzone, which maintains
.dropzone--over and puts a dropped file into the zone's own input as a
change event — so an existing handler, Blazor's InputFile included,
sees a dropped file exactly as it sees a chosen one. Leave the attribute off to own the drag
handling yourself.
Two things it does that are easy to get wrong alone: dragleave fires when the
pointer moves onto a child, so a naive enter/leave pair flickers and then sticks as
soon as the zone contains an icon and a label. And dragover must have its default
prevented, or the browser refuses the drop and navigates to the file.
The file list is a real <ul>, so the count is announced. Sizes are
monospace so a column of them lines up. .file-item--error carries the icon as
well as the colour.
- order-export-2026-07-31.pdf 1.2 MB
- screen recording.mov — not an accepted type 48 MB
<div style="max-width:460px">
<label class="dropzone" data-dropzone>
<input type="file" multiple class="visually-hidden" />
<i class="ri-upload-cloud-2-line"></i>
<span>Drop files here, or click to choose</span>
<span class="form-hint">PDF, PNG or EML — up to 10 MB each</span>
</label>
<ul class="file-list">
<li class="file-item">
<i class="ri-file-pdf-2-line"></i>
<span class="file-item-name">order-export-2026-07-31.pdf</span>
<span class="file-item-size">1.2 MB</span>
<button class="chip-dismiss" type="button" aria-label="Remove order-export-2026-07-31.pdf">
<i class="ri-close-line"></i>
</button>
</li>
<li class="file-item file-item--error">
<i class="ri-error-warning-line"></i>
<span class="file-item-name">screen recording.mov — not an accepted type</span>
<span class="file-item-size">48 MB</span>
<button class="chip-dismiss" type="button" aria-label="Remove screen recording.mov">
<i class="ri-close-line"></i>
</button>
</li>
</ul>
</div>
The three states
Idle, dragged-over, and rejected. .dropzone--over is set by the script and has no
CSS pseudo-class equivalent, so it is the one state an app cannot express in markup alone.
Rejection is aria-invalid on the zone plus a .form-error naming the
file and the limit. Set the attribute rather than a class: it is what a screen reader
announces, so the two cannot disagree. “Invalid file” is not a message — say which
file and why.
screen recording.mov is 48 MB. The limit is 10 MB.
<div class="dr-col dr-gap-2" style="max-width:460px">
<label class="dropzone" data-dropzone>
<input type="file" multiple class="visually-hidden" />
<i class="ri-upload-cloud-2-line"></i>
<span>Drop files here, or click to choose</span>
<span class="form-hint">Idle</span>
</label>
<div class="dropzone dropzone--over">
<i class="ri-upload-cloud-2-line"></i>
<span>Drop files here, or click to choose</span>
<span class="form-hint">Something is being dragged over it</span>
</div>
<div>
<div class="dropzone" aria-invalid="true">
<i class="ri-error-warning-line"></i>
<span>Drop files here, or click to choose</span>
<span class="form-hint">Rejected</span>
</div>
<p class="form-error" id="dz-error">
<i class="ri-error-warning-line"></i> screen recording.mov is 48 MB. The limit is 10 MB.
</p>
</div>
</div>
Inside a form
The whole field in place: a <fieldset class="form-section"> so the legend is
announced with every control inside it, the zone, the list of what is attached, and the
.form-actions row the form ends with.
.form-label--required is decoration. Put required on the input, which
is what is announced and enforced.
<fieldset class="form-section" style="max-width:520px">
<legend class="form-section-title">Proof of delivery</legend>
<p class="form-section-hint">Attached to ORD-4209 and visible to the customer.</p>
<div class="form-section-body">
<div class="form-field">
<label class="form-label form-label--required" for="pod-file">Signed note</label>
<label class="dropzone" data-dropzone>
<input type="file" id="pod-file" required />
<i class="ri-upload-cloud-2-line"></i>
<span>Drop the scan here, or click to choose</span>
<span class="form-hint">PDF or PNG, up to 10 MB</span>
</label>
</div>
<ul class="file-list">
<li class="file-item">
<i class="ri-file-pdf-2-line"></i>
<span class="file-item-name">pod-ord-4209.pdf</span>
<span class="file-item-size">640 KB</span>
<button class="chip-dismiss" type="button" aria-label="Remove pod-ord-4209.pdf">
<i class="ri-close-line"></i>
</button>
</li>
</ul>
<div class="form-field">
<label class="form-label" for="pod-note">Note</label>
<input class="form-input" id="pod-note" type="text" placeholder="Left with the concierge" />
</div>
</div>
<div class="form-actions">
<button class="btn" type="button">Cancel</button>
<button class="btn btn-go" type="button"><i class="ri-check-line"></i> Attach and close</button>
</div>
</fieldset>