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 (12)
§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.
§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/*).
§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. |
§API
Generated from file-input.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type FileInputState = 'default' | 'dragover' | 'selected' | 'error' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||
|---|---|---|---|---|---|---|---|
default | No files - setting it clears the selection. No config. | ||||||
dragover | Files are dragged over the zone. No config. | ||||||
selected | Files chosen.
| ||||||
error | Some files were refused.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends FileInputState>(name: S, config?: FileInputStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: FileInputState; config: FileInputStateConfigs[FileInputState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.fileInputApi.setState<S extends FileInputState>(el: HTMLElement, name: S, config?: FileInputStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.fileInputApi.getState(el: HTMLElement): { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.fileInputApi.render(state: { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.fileInputApi.store(el: HTMLElement): Store<{ name: FileInputState; config: FileInputStateConfigs[FileInputState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.fileInputApi.commit<S extends FileInputState>(el: HTMLElement, name: S, config?: FileInputStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.fileInputStates: FileInputState[] | The declared states, 'default' first: default, dragover, selected, error. |
Events
| Event | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
file-drop:rejected | Fires when chosen or dropped files are refused (type, size, count) - the files and the message shown.
|
Types
| Type | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
FileDropRejectedDetail | What file-drop:rejected carries.
|
§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, defussQuery, componentState, bindComponent, textLocale } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build./** What file-drop:rejected carries. */interface FileDropRejectedDetail { /** the files that were refused */ files: File[]; /** the message the component shows for them */ message: string;}/** default = empty; dragover = files hover the zone; selected = files * chosen; error = some files were rejected (config.message). */const fileInputStates = ['default', 'dragover', 'selected', 'error'];/** setState() configs per state (getState() reports the rejection message shown). */export interface FileInputStateConfigs { /** No files - setting it clears the selection. */ default: {}; /** Files are dragged over the zone. */ dragover: {}; /** Files chosen. */ selected: { /** placeholder entries for the list (a name, or name + size in bytes + MIME type) */ files?: Array<string | { name: string; size?: number; type?: string }>; }; /** Some files were refused. */ error: { /** the message shown (getState() reports the live one) */ message?: string; };}const inputOf = (el) => dfDollar(el).find<HTMLInputElement>('.file-drop-input').get(0);/** 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) => textLocale(el);/** 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 = dfDollar(el).find('.file-drop-list').get(0); if (!list) return; (el._urls || []).forEach((u) => URL.revokeObjectURL(u)); el._urls = []; // the list is rebuilt per change: emptied and refilled through df$ dfDollar(list).empty().append( [...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'; dfDollar(remove).html(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 = dfDollar(el).find('.file-drop-error').get(0); 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); // Fires when chosen or dropped files are refused (type, size, count) - the files and the message shown. if (!quiet && rejected.length) el.dispatchEvent(new CustomEvent<FileDropRejectedDetail>('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);}/** * The markup of a state, for render(), on a detached copy of the authored * markup: the error line - the message in 'error', empty otherwise - as * apply() / the error state write it. The file list is built from the * input's FileList (blob-URL thumbnails, sizes): runtime-owned. */function applyMarkup(el, stateName, config) { const err = dfDollar(el).find('.file-drop-error').first(); if (!err.get(0)) return; err.attr('role', 'alert'); if (stateName === 'error') err.text(config?.message || 'Not added: archive.zip (type)'); else if (stateName !== 'dragover') err.text('');}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) // { files: [{ name, size, type }] } - or getState()'s plain names const files = (config?.files || [{ name: 'report.pdf', size: 248000, type: 'application/pdf' }]).map((f) => (typeof f === 'string' ? { name: f } : f)).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 = dfDollar(el).find('.file-drop-error').get(0); 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 = componentState({ component: 'file-input', states: fileInputStates, apply: (el, state) => triggerStateChange(el, state.name, state.config), read: (el, state) => { const input = inputOf(el); return { name: el.dataset.stateName || 'default', config: { ...state.config, count: input.files.length, files: [...input.files].map((f) => f.name), // the live rejection message, so setState('error', getState().config) keeps it ...(el.dataset.stateName === 'error' ? { message: dfDollar(el).find('.file-drop-error').text() } : {}), }, }; }, markup: (el, state) => applyMarkup(el, state.name, state.config),});df$.fileInputApi = fileInputApi;df$.fileInputStates = fileInputStates;function init() { dfDollar('.file-drop:not([data-init])').toArray().forEach((el) => { const input = inputOf(el); if (!input) return; el.dataset.init = ''; el.dataset.stateName = 'default'; el._kept = []; // the error line is a live region from the start (not only once a // rejection has been written) dfDollar(el).find('.file-drop-error').attr('role', 'alert'); // el.store + el.api (AGENTS.md "State through stores") bindComponent(el, fileInputApi); const zone = dfDollar(el).find('.file-drop-zone').get(0) || 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