File
.file is a dashed, centred drop zone that goes on a
<label> wrapping a real <input type="file">.
The input is hidden, but not with display: none — that would take it out
of the tab order and make the control unreachable by keyboard.
When to use it
Use it when uploading is a primary action on the page: an import screen, an avatar picker, an attachment area. The large target and the dashed border say "put something here" in a way a small Choose file button does not.
<label class="file" style="max-inline-size:26rem">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg>
<span>Drop a CSV here, or choose a file</span>
<span class="text-sm text-muted">Up to 10 MB</span>
<input type="file" accept=".csv">
</label>
<label class="file" style="max-inline-size:26rem">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg>
<span>Drop a CSV here, or choose a file</span>
<span class="text-sm text-muted">Up to 10 MB</span>
<input type="file" accept=".csv">
</label>
Hiding the input without losing it
.file input[type="file"] {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
}
A file input cannot be styled — the browser draws the button, and its appearance is not yours to change. Every custom upload control therefore hides the real input and puts something else on top. How it is hidden is the part that matters:
-
display: noneremoves it from the tab order. The control becomes mouse-only. This is the most common upload defect there is. -
visibility: hiddendoes the same — it is removed from the accessibility tree as well. - 1px and transparent keeps everything. The input is still focusable, still announced, still operable with Enter or Space. It is simply not visible.
Because the input is inside the <label>, clicking anywhere in the
zone opens the file picker with no JavaScript at all.
:focus-within is the focus ring
The input is invisible, so focusing it would show nothing. .file:hover,
.file:focus-within gives the zone the brand border and tint, so
tabbing to the control lights up the thing the reader can actually see.
This is the pattern to copy whenever a real control is visually replaced: keep the
control, and move its states onto the wrapper with :focus-within,
:has() or a sibling selector.
<div class="stack stack-3" style="max-inline-size:26rem">
<label class="file">
<span>Tab into this one</span>
<input type="file">
</label>
<label class="file">
<span>Then this one</span>
<input type="file">
</label>
</div>
<div class="stack stack-3" style="max-inline-size:26rem">
<label class="file">
<span>Tab into this one</span>
<input type="file">
</label>
<label class="file">
<span>Then this one</span>
<input type="file">
</label>
</div>
Dropping is not implemented
.file draws the visual vocabulary of a drop target — a dashed border, a
hover state, an upload icon — and Deck ships no drop handler.
Dragging a file onto it does nothing. Clicking works, because the label wraps a real
input; dragging does not, because dragging requires JavaScript that is not there.
An affordance that does not do what it looks like it does is worse than no affordance:
a reader drags a file, nothing happens, and they conclude the page is broken rather
than that they should have clicked. This is recorded in FINDINGS.md. The
honest options are to ship the handler or to stop drawing a drop zone; until one of
them happens, the label text should not say "drop".
The handler is about fifteen lines, and needs dragover prevented or the
browser navigates to the file:
const zone = document.querySelector('.file');
const input = zone.querySelector('input[type="file"]');
zone.addEventListener('dragover', e => {
e.preventDefault(); // without this the browser opens the file
zone.classList.add('is-over');
});
zone.addEventListener('dragleave', () => zone.classList.remove('is-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('is-over');
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
.is-over already exists in src/23-inputs.css — it is used
by the editor's character counter — so the class is available, though nothing in
.file's own rules styles it yet.
Classes
.file has no .file-* variants, so the extractor does not
treat it as a component root and there is no completeness check on this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.file |
deck.components |
A dashed drop zone wrapping a real file input. The input is 1px and transparent rather than display: none, so it keeps its place in the tab order, and :focus-within is what makes that focus visible. Deck styles the zone; dropping needs a handler of your own. | 06-forms.css:331 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--brand-500 |
oklch(60.8% var(--chroma-brand) var(--hue-brand)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--r-md |
12px |
|
--space-2 |
.5rem |
|
--space-6 |
1.5rem |
|
--dur-1 |
110ms |
Accessibility
-
The input keeps its tab stop, which is the whole reason it is 1px
and transparent rather than
display: none. Tab reaches it, Enter and Space open the picker, and the file dialog is the platform's own. -
The zone's text is the accessible name. The label wraps the input,
so everything inside it is read as the control's name — including the "Up to 10 MB"
hint, which makes for a long name. Move a long hint outside the label and point at
it with
aria-describedby. -
Focus is visible on the zone, not the input.
:focus-withinis doing that. Remove it and the control becomes focusable but invisibly so, which is worse than not being focusable at all. - Dragging is pointer-only and always will be. Even with the handler above, drag-and-drop has no keyboard equivalent. The click path is not a fallback, it is the primary path — which is another reason not to lead the label text with "Drop".
-
Selected files are not announced. The browser shows the file name
inside its own button, which is hidden here — so after choosing a file the zone
looks exactly as it did before. You must render the selection
yourself, and put it in an
aria-liveregion or move focus to it. This is a real gap in the component. -
acceptis a filter, not validation. It narrows the file dialog and can be bypassed. Check the type on the server. - Touch target. The zone is 1.5rem of padding around centred content, so it clears the minimum comfortably — the one part of this component with nothing to worry about.
Right to left
The zone is a grid with place-items: center and symmetrical padding, so
there is nothing directional to mirror. The text inside it follows the document
direction as ordinary text does.
<label dir="rtl" class="file" style="max-inline-size:26rem">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg>
<span>اختر ملفًا</span>
<input type="file">
</label>
<label dir="rtl" class="file" style="max-inline-size:26rem">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg>
<span>اختر ملفًا</span>
<input type="file">
</label>
Reduced motion
The border and background transition over
110ms on hover and focus; the global
reset collapses both to .01ms under
prefers-reduced-motion: reduce. The states still happen, immediately.
Printing
.file is not named in src/99-print.css, so it prints as a
dashed box with its prompt text inside. For a printed form that is arguably right —
it reads as a space for an attachment — but the dashed border is a screen affordance
and the icon is meaningless on paper.
If the printed version matters, render the chosen file's name as text alongside the zone; that text prints and the zone can be hidden.
Overriding it
@layer app.components {
/* A compact zone for a sidebar */
.file { padding: var(--space-4); }
/* Style the drag-over state the handler sets */
.file.is-over {
border-color: var(--brand-600);
background: var(--brand-soft);
border-style: solid;
}
}
Do not replace the hiding technique with display: none, however tempting
it looks. That single change is what breaks keyboard access, and nothing on the page
will tell you.
When not to use it
-
Not when uploading is a minor action. A drop zone is large and
demands attention. For an optional attachment on a long form, a
.btnbeside the field is proportionate. - Not without rendering the selection. The browser's own file name display is hidden, so a zone with no selected-file list leaves the reader unsure whether anything happened. This is the component's biggest gap.
- Not with "Drop" in the label, until you ship the handler. The dashed border already implies it; the words make a promise the page does not keep.
- Not for an avatar. A picture needs a preview and a crop. A drop zone with no preview means uploading and then looking at the result to find out what you got.
- Not as the only way to add something. If a file can also be pasted or linked, offer those too — dragging is impossible on many devices and awkward on most.