Theme
On this page (11)
Component Skill — components/file-input/component-skill.md

Native basis

<input type="file"> element with custom ::file-selector-button styling.

Web Platform APIs

<input type="file">::file-selector-buttonDataTransferDrag and DropcreateObjectURL()

Classes

.file-input.file-drop.file-drop-zone.file-drop-input.file-drop-icon.file-drop-title.file-drop-hint.file-drop-error.file-drop-list

Notes

• Reuses the .input styling pattern for consistency.

• The ::file-selector-button is styled as a muted button with hover effect.

• The existing .input[type="file"] already covers this - the file-input component provides a standalone class.

§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 zone
  • selected - files chosen (config.files sets 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:

StateTypeValuesDefaultDescription
disabledbooleantrue, falsefalsePicker off (native disabled attribute).
sizeenumsm, md, lg"md"Control scale (display sizes).
dragoverbooleantrue, falsefalseDrop zone: files hover it.
selectedbooleantrue, falsefalseDrop zone: files chosen.
errorbooleantrue, falsefalseDrop 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