File InputATM
A styled file picker - as a compact field, or as a drag & drop card. Built on native <input type="file">: in the card the invisible input takes both the click and the drop, and a small script filters, appends and lists the files.
On this page (11)
§Drag and drop
A card to drop files on - or click it. The native input covers the card, so the drop lands on it and forms submit it as usual; the script highlights the drag, keeps only images and PDFs up to 10 MB (a drop bypasses accept), appends to the selection and lists the files with previews and remove buttons.
§Several images, nothing else
multiple + accept='image/*' + data-max-files='4': the picker shows images only, and dropped PDFs or a fifth image are named in the error line instead of being added.
§One document
Without multiple a new pick replaces the old one; accept lists extensions (.pdf, .doc, .docx) and MIME types alike.
§Compact drop zone
data-size='sm' - one row, for forms. It is still the whole drop target.
§Default
Single file selection.
§Multiple files
The native multiple attribute - the picker allows several files; the field names the count.
§Accept filter
accept limits the picker to types - here images; list extensions and MIME types comma-separated (.pdf,image/*).
§Sizes
The control follows the shared input ladder via data-size - md is 2.25rem, matching .input.
§States
Named states of the drop zone, bound on each .file-drop:
default- no files (setState clears the selection)dragover- files are dragged over the zoneselected- files chosen (config.filessets placeholders)error- some files were rejected (config.message)
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/file-input-{state}.png.
Machine contract - verified against file-input.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
disabled | boolean | true, false | false | Picker off (native disabled attribute). |
size | enum | sm, md, lg | "md" | Control scale (display sizes). |
dragover | boolean | true, false | false | Drop zone: files hover it. |
selected | boolean | true, false | false | Drop zone: files chosen. |
error | boolean | true, false | false | Drop zone: some files rejected. |
§CSS view file
/* -- File Input component --------------------------------------- */@layer components { .file-input { /* see .input - border-box so the declared ladder height is the real box; unsized default = the ladder's md step (field standard) */ box-sizing: border-box; width: 100%; /* the chosen file name is the whole point of the control's text part - never let a flex/grid row squeeze it below a readable path length */ min-inline-size: min(100%, 24rem); height: 2.25rem; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); font-size: 0.875rem; font-family: var(--font-sans); color: var(--foreground); outline: none; box-shadow: var(--shadow-xs); transition: border-color 150ms, box-shadow 150ms; padding: 0; cursor: pointer; &::file-selector-button { /* % resolves against the border-box's CONTENT height once the control is border-box - 100% would leave 1px gaps above/below the divider; +2px makes the button flush with the frame (its original visual) */ height: calc(100% + 2px); border: none; border-right: 1px solid var(--input); background: var(--muted); color: var(--foreground); font-size: 0.875rem; font-weight: 500; font-family: var(--font-sans); padding: 0 0.75rem; margin-right: 0.75rem; cursor: pointer; transition: background 150ms; &:hover { background: var(--accent); } } &:focus { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } /* Disabled = legible but inert: muted surface + text inside the full-strength border - never an opacity fade */ &:disabled { background-color: var(--muted); color: var(--muted-foreground); box-shadow: none; cursor: not-allowed; &::file-selector-button { color: var(--muted-foreground); cursor: not-allowed; &:hover { background: var(--muted); } } } /* -- Sizes: same ladder as .input/.date-input (md = 2.25rem; the unsized default keeps the historical 2.5rem). The file-selector button fills the container (height:100%), so only the box and the font scale. */ &[data-size="xs"] { height: 1.75rem; font-size: 0.75rem; &::file-selector-button { padding: 0 0.5rem; margin-right: 0.5rem; font-size: 0.75rem; } } &[data-size="sm"] { height: 2rem; font-size: 0.8125rem; &::file-selector-button { padding: 0 0.625rem; margin-right: 0.625rem; font-size: 0.8125rem; } } &[data-size="md"] { height: 2.25rem; font-size: 0.875rem; } &[data-size="lg"] { height: 2.75rem; font-size: 1rem; &::file-selector-button { padding: 0 1rem; margin-right: 1rem; font-size: 1rem; } } &[data-size="xl"] { height: 3.25rem; font-size: 1.125rem; &::file-selector-button { padding: 0 1.25rem; margin-right: 1.25rem; font-size: 1.125rem; } } } /* -- Drop zone --------------------------------------------------------- .file-drop wraps a <label class="file-drop-zone"> (the card) whose native <input type="file" class="file-drop-input"> covers it, invisible - so a click opens the picker and a DROP lands on the input itself: the browser fills input.files natively, no JavaScript needed. file-input.js adds the drag highlight, filters dropped files by accept / data-max-size / data-max-files (the browser does not filter drops), appends to the selection instead of replacing it, and lists the files in .file-drop-list with previews and remove buttons. */ .file-drop { display: grid; gap: 0.75rem; width: 100%; } .file-drop-zone { position: relative; display: grid; justify-items: center; gap: 0.375rem; padding: 2rem 1.5rem; border: 2px dashed color-mix(in oklch, var(--foreground) 22%, transparent); border-radius: var(--radius-xl); background-color: var(--card); color: var(--card-foreground); text-align: center; cursor: pointer; transition: border-color 150ms ease, background-color 150ms ease, scale 150ms ease; &:hover { border-color: color-mix(in oklch, var(--foreground) 40%, transparent); } &:has(.file-drop-input:focus-visible) { outline: 2px solid var(--ring); outline-offset: 2px; } &:has(.file-drop-input:disabled) { opacity: 0.55; cursor: not-allowed; } } .file-drop-input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: inherit; font-size: 0; /* no native "No file chosen" hit-box quirks */ } .file-drop-icon { display: grid; place-items: center; width: 3rem; height: 3rem; margin-bottom: 0.25rem; border-radius: 9999px; background-color: var(--muted); color: var(--muted-foreground); font-size: 1.5rem; line-height: 1; transition: background-color 150ms ease, color 150ms ease, translate 150ms ease; & svg { width: 1.5rem; height: 1.5rem; } } .file-drop-title { font-size: 0.9375rem; font-weight: 600; text-wrap: balance; & u, & .file-drop-browse { color: var(--primary); text-decoration: underline; text-underline-offset: 3px; } } .file-drop-hint { font-size: 0.8125rem; color: var(--muted-foreground); text-wrap: balance; } /* dragging files over the zone */ .file-drop[data-state-name="dragover"] .file-drop-zone { border-color: var(--primary); border-style: solid; background-color: color-mix(in oklch, var(--primary) 6%, var(--card)); scale: 1.01; & .file-drop-icon { background-color: var(--primary); color: var(--primary-foreground); translate: 0 -2px; } } /* rejected files (wrong type, too big, too many) */ .file-drop[data-state-name="error"] .file-drop-zone { border-color: var(--destructive); } .file-drop-error { margin: 0; font-size: 0.8125rem; color: var(--destructive); &:empty { display: none; } } /* compact: one row - icon, text, and the whole row is the target */ .file-drop[data-size="sm"] .file-drop-zone { grid-template-columns: auto 1fr; justify-items: start; align-items: center; column-gap: 0.75rem; row-gap: 0; padding: 0.75rem 1rem; text-align: start; border-radius: var(--radius-lg); & .file-drop-icon { grid-row: span 2; width: 2.25rem; height: 2.25rem; margin: 0; font-size: 1.125rem; & svg { width: 1.125rem; height: 1.125rem; } } } /* -- The file list ---------------------------------------------------- */ .file-drop-list { display: grid; gap: 0.5rem; margin: 0; padding: 0; list-style: none; &:empty { display: none; } } .file-drop-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.75rem; padding: 0.5rem 0.5rem 0.5rem 0.625rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background-color: var(--card); } .file-drop-thumb { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--muted); color: var(--muted-foreground); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; & img { width: 100%; height: 100%; object-fit: cover; } } .file-drop-name { overflow: hidden; font-size: 0.875rem; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; } .file-drop-meta { display: block; font-size: 0.75rem; font-weight: 400; color: var(--muted-foreground); } .file-drop-remove { display: grid; place-items: center; width: 2rem; height: 2rem; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--muted-foreground); cursor: pointer; &:hover { background-color: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } & svg { width: 1rem; height: 1rem; } } @media (forced-colors: active) { .file-drop-zone { border-color: CanvasText; } .file-drop[data-state-name="dragover"] .file-drop-zone { border-color: Highlight; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .file-input, .file-input *, .file-input::before, .file-input::after, .file-input *::before, .file-input *::after, .file-input::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}§JS view file
/* -- File Input component ----------------------------------------- */// The plain .file-input is CSS only. This module drives the .file-drop zone:// the native <input type="file"> covers the card, so clicking and dropping// are the browser's own; on top of that it highlights a drag, filters the// files by accept / data-max-size / data-max-files (a DROP bypasses accept),// appends to a multiple selection instead of replacing it, lists the files// (image previews, Intl-formatted sizes, remove buttons) and exposes the// named State API (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();/** default = empty; dragover = files hover the zone; selected = files * chosen; error = some files were rejected (config.message). */const fileInputStates = ['default', 'dragover', 'selected', 'error'];const inputOf = (el) => el.querySelector('.file-drop-input');/** Does a file match the input's accept list (".pdf", "image/*", "image/png")? */function accepts(input, file) { const list = (input.accept || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); if (!list.length) return true; const name = file.name.toLowerCase(); const type = (file.type || '').toLowerCase(); return list.some((a) => (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));}const lang = (el) => el.closest('[lang]')?.lang || undefined;/** 1536 → "1.5 kB" (Intl unit formatting, the locale's own). */function formatSize(el, bytes) { const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte']; let i = 0; let n = bytes; while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; } return new Intl.NumberFormat(lang(el), { style: 'unit', unit: units[i], unitDisplay: 'short', maximumFractionDigits: i ? 1 : 0 }).format(n);}const ICON_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';/** Rebuild input.files from a File[] (DataTransfer is the only way to set it). */function setFiles(input, files) { const dt = new DataTransfer(); for (const f of files) dt.items.add(f); input.files = dt.files;}function renderList(el) { const input = inputOf(el); const list = el.querySelector('.file-drop-list'); if (!list) return; (el._urls || []).forEach((u) => URL.revokeObjectURL(u)); el._urls = []; list.replaceChildren( ...[...input.files].map((file, i) => { const li = document.createElement('li'); li.className = 'file-drop-item'; const thumb = document.createElement('span'); thumb.className = 'file-drop-thumb'; thumb.setAttribute('aria-hidden', 'true'); if (file.type.startsWith('image/')) { const img = document.createElement('img'); img.alt = ''; img.src = URL.createObjectURL(file); el._urls.push(img.src); thumb.append(img); } else { thumb.textContent = (file.name.split('.').pop() || 'file').slice(0, 4); } const name = document.createElement('div'); name.className = 'file-drop-name'; name.textContent = file.name; const meta = document.createElement('span'); meta.className = 'file-drop-meta'; meta.textContent = formatSize(el, file.size); name.append(meta); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'file-drop-remove'; remove.innerHTML = ICON_X; remove.setAttribute('aria-label', `Remove ${file.name}`); remove.addEventListener('click', () => { setFiles(input, [...input.files].filter((_, k) => k !== i)); el._kept = [...input.files]; apply(el, [], true); // tell form listeners - flagged, so our own change handler skips it el._removing = true; input.dispatchEvent(new Event('change', { bubbles: true })); el._removing = false; }); li.append(thumb, name, remove); return li; }), );}/** Show the rejections (or clear them) and settle the state name. */function apply(el, rejected, quiet = false) { const input = inputOf(el); const err = el.querySelector('.file-drop-error'); const message = rejected.length ? `Not added: ${rejected.map((r) => `${r.file.name} (${r.why})`).join(', ')}` : ''; if (err) { err.textContent = message; err.setAttribute('role', 'alert'); } el.dataset.stateName = rejected.length ? 'error' : input.files.length ? 'selected' : 'default'; renderList(el); if (!quiet && rejected.length) el.dispatchEvent(new CustomEvent('file-drop:rejected', { bubbles: true, detail: { files: rejected.map((r) => r.file), message } }));}/** Merge the new pick into the kept files, filter, write input.files back. */function onPick(el) { const input = inputOf(el); const incoming = [...input.files]; const kept = input.multiple ? el._kept || [] : []; const maxSize = parseFloat(el.dataset.maxSize || 'Infinity'); const maxFiles = input.multiple ? parseFloat(el.dataset.maxFiles || 'Infinity') : 1; const key = (f) => `${f.name}|${f.size}|${f.lastModified}`; const seen = new Set(kept.map(key)); const out = [...kept]; const rejected = []; for (const file of incoming) { if (seen.has(key(file))) continue; if (!accepts(input, file)) rejected.push({ file, why: 'type' }); else if (file.size > maxSize) rejected.push({ file, why: `over ${formatSize(el, maxSize)}` }); else if (out.length >= maxFiles) rejected.push({ file, why: `max ${maxFiles}` }); else { out.push(file); seen.add(key(file)); } } setFiles(input, out); el._kept = out; apply(el, rejected);}function triggerStateChange(el, stateName, config) { const input = inputOf(el); switch (stateName) { case 'default': setFiles(input, []); el._kept = []; apply(el, [], true); break; case 'dragover': el.dataset.stateName = 'dragover'; break; case 'selected': { // config.files: [{ name, size, type }] - placeholder files (demos, tests) const files = (config?.files || [{ name: 'report.pdf', size: 248000, type: 'application/pdf' }]).map( // real bytes (capped at 5 MB) so size, type and name read true (f) => new File([new Uint8Array(Math.min(f.size ?? 0, 5e6))], f.name, { type: f.type || '' }), ); setFiles(input, files); el._kept = [...input.files]; apply(el, [], true); break; } case 'error': { const err = el.querySelector('.file-drop-error'); if (err) err.textContent = config?.message || 'Not added: archive.zip (type)'; el.dataset.stateName = 'error'; break; } }}/** Registry-level API; pass the .file-drop explicitly. Unknown names throw. */export const fileInputApi = { setState(el, stateName, config = {}) { if (!fileInputStates.includes(stateName)) { throw new Error(`file-input: unknown state "${stateName}" (supported: ${fileInputStates.join(', ')})`); } el._stateConfig = config; triggerStateChange(el, stateName, config); }, getState(el) { const input = inputOf(el); return { name: el.dataset.stateName || 'default', config: { ...el._stateConfig, count: input.files.length, files: [...input.files].map((f) => f.name) }, }; },};df$.fileInputApi = fileInputApi;df$.fileInputStates = fileInputStates;function init() { document.querySelectorAll('.file-drop:not([data-init])').forEach((el) => { const input = inputOf(el); if (!input) return; el.dataset.init = ''; el.dataset.stateName = 'default'; el._kept = []; el.api = { setState: (stateName, config) => fileInputApi.setState(el, stateName, config), getState: () => fileInputApi.getState(el), }; const zone = el.querySelector('.file-drop-zone') || el; let depth = 0; // dragenter/leave fire for every child - count them zone.addEventListener('dragenter', (e) => { if (!e.dataTransfer?.types.includes('Files') || input.disabled) return; depth++; el._before = el.dataset.stateName === 'dragover' ? el._before : el.dataset.stateName; el.dataset.stateName = 'dragover'; }); zone.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (!depth && el.dataset.stateName === 'dragover') el.dataset.stateName = el._before || 'default'; }); zone.addEventListener('drop', () => { depth = 0; if (el.dataset.stateName === 'dragover') el.dataset.stateName = el._before || 'default'; }); input.addEventListener('change', () => { // our own re-dispatch after a remove carries no new pick if (!el._removing) onPick(el); }); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub