Inputs

File drop

A drop zone over a real file input that lists chosen files with size, progress and a remove button.

Built on
<input type="file">DataTransferDrag and drop<progress>:has()
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.3 kB
Built from
ButtonTruncate
Brand assets

  • brand-kit.zip4.2 MB
Code
import { FileDrop } from "@/components/equip/file-drop";

export default function Demo() {
  return (
    <div style={{ width: 224 }}>
      <FileDrop
        label="Brand assets"
        accept=".zip,.pdf"
        hint="ZIP or PDF, up to 50 MB"
        maxSize={50_000_000}
        defaultFiles={[{ name: "brand-kit.zip", size: 4_200_000 }]}
      />
    </div>
  );
}

Examples

Types, size and count

Dropped files skip the browser's accept filter, so the part checks type and size itself and says which files it left out.

Receipts

Code
import { FileDrop } from "@/components/equip/file-drop";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 320 }}>
      <FileDrop
        label="Receipts"
        name="receipts"
        accept=".pdf,.png,.jpg"
        multiple
        maxFiles={3}
        maxSize={10_000_000}
        hint="PDF, PNG or JPG, up to 10 MB each, 3 files"
      />
    </div>
  );
}

Upload progress

Progress is keyed by file name; the part shows it but does no uploading of its own.

Quarterly report

  • q3-board-deck-final.pdf18 MB
  • revenue-by-region-2026.xlsx912 KB
Code
import { FileDrop } from "@/components/equip/file-drop";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 320 }}>
      <FileDrop
        label="Quarterly report"
        multiple
        hint="Any file, up to 200 MB"
        defaultFiles={[
          { name: "q3-board-deck-final.pdf", size: 18_400_000 },
          { name: "revenue-by-region-2026.xlsx", size: 912_000 },
        ]}
        progress={{ "q3-board-deck-final.pdf": 0.64, "revenue-by-region-2026.xlsx": 1 }}
      />
    </div>
  );
}

Disabled

Attachments

Code
import { FileDrop } from "@/components/equip/file-drop";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 320 }}>
      <FileDrop label="Attachments" disabled hint="Uploads are paused while the workspace is archived." />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label. Names the file input.
acceptstringnoneSame format as the native attribute, e.g. ".pdf,image/*". Also checked for dropped files.
multiplebooleanfalseAllows more than one file. New files add to the list.
maxSizenumbernoneLargest accepted file, in bytes.
maxFilesnumbernoneMost files at once when multiple is set.
onFilesChange(files: File[]) => voidnoneCalled with the accepted files after every add or remove.
progressRecord<string, number>noneUpload progress by file name, 0 to 1. A file with an entry shows a bar.
hintReactNodenoneShown in the zone and read as its description, e.g. types and sizes.
disabledbooleanfalseDisables browsing, dropping and removing.
namestringnoneForm field name of the file input.
defaultFiles{ name: string; size: number }[]noneDisplay-only rows shown before anything is chosen, such as files already uploaded.
...restOmit<ComponentProps<"div">, "onChange">noneEvery native div attribute, applied to the root.

Anatomy

root.eq-file-dropCarries data-dragging while files are held over the zone.
label.eq-file-drop-label
zone.eq-file-drop-zoneA label around the file input, so a click anywhere opens the picker.
input.eq-file-drop-inputThe native file input, visually hidden but focusable.
icon.eq-file-drop-icon
text.eq-file-drop-text
cta.eq-file-drop-cta
browse.eq-file-drop-browse
hint.eq-file-drop-hint
error.eq-file-drop-errorA polite live region naming each file left out and why.
list.eq-file-drop-list
file.eq-file-drop-file
file icon.eq-file-drop-file-icon
name.eq-file-drop-nameTruncate, so the extension stays in view.
size.eq-file-drop-size
remove.eq-file-drop-removeA square extra small quiet Button named like Remove brand-kit.zip.
progress.eq-file-drop-progress

Keyboard

TabMoves to the zone, then to each remove button.
EnterSpaceOn the zone, opens the file picker. On a remove button, removes that file.

Notes

  • Dropping is the pointer shortcut; the keyboard way to add files is the same picker a click opens.
  • The input's own file list is rewritten after every drop and removal, so a plain form post sends exactly the files shown.
  • Dropped files skip the browser's accept filter, so type and size are checked again and refused files are named with the reason.
  • Removing a file moves focus back to the zone.
  • Writing the file list back uses the DataTransfer constructor; an engine without it keeps the files from the last pick, so a form could send a removed file.
  • Rows from defaultFiles are display only; they are not in the input and are not submitted.
  • The part does not upload anything. Send the files from onFilesChange or a form, and pass progress back by file name.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/file-drop.tsx263 lines
"use client";

import { useId, useRef, useState } from "react";
import type { ComponentProps, DragEvent, ReactNode } from "react";
import { Button } from "./button";
import { Truncate } from "./truncate";
import "./file-drop.css";

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

function formatSize(bytes: number): string {
  if (bytes < 1000) return `${bytes} B`;
  const units = ["KB", "MB", "GB", "TB"];
  let n = bytes / 1000;
  let unit = 0;
  while (n >= 1000 && unit < units.length - 1) {
    n /= 1000;
    unit += 1;
  }
  return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[unit]}`;
}

/** The same rules as the accept attribute: extensions, exact types and type/* wildcards. */
function matches(file: File, accept: string | undefined): boolean {
  if (!accept) return true;
  const fileName = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept
    .split(",")
    .map((rule) => rule.trim().toLowerCase())
    .filter(Boolean)
    .some((rule) =>
      rule.startsWith(".") ? fileName.endsWith(rule) : rule.endsWith("/*") ? type.startsWith(rule.slice(0, -1)) : type === rule,
    );
}

type Listed = { name: string; size: number };
type Refusal = { name: string; reason: string };

export type FileDropProps = Omit<ComponentProps<"div">, "onChange"> & {
  label: ReactNode;
  /** Same format as the native attribute, e.g. ".pdf,image/*". Also checked for dropped files. */
  accept?: string;
  multiple?: boolean;
  /** Largest accepted file, in bytes. */
  maxSize?: number;
  /** Most files at once when multiple is set. */
  maxFiles?: number;
  onFilesChange?: (files: File[]) => void;
  /** Upload progress by file name, 0 to 1. A file with an entry shows a bar. */
  progress?: Record<string, number>;
  /** Shown in the zone and read as its description, e.g. the accepted types and sizes. */
  hint?: ReactNode;
  disabled?: boolean;
  name?: string;
  /** Rows to show before anything is chosen, such as files already on the server. Display only. */
  defaultFiles?: Listed[];
};

export function FileDrop({
  label,
  accept,
  multiple = false,
  maxSize,
  maxFiles,
  onFilesChange,
  progress,
  hint,
  disabled = false,
  name,
  defaultFiles,
  className,
  ...rest
}: FileDropProps) {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const depth = useRef(0);
  const [files, setFiles] = useState<File[]>([]);
  const [listed, setListed] = useState<Listed[]>(defaultFiles ?? []);
  const [refused, setRefused] = useState<Refusal[]>([]);
  const [dragging, setDragging] = useState(false);
  const limit = multiple ? (maxFiles ?? Infinity) : 1;

  const labelId = `${id}-label`;
  const hintId = present(hint) ? `${id}-hint` : undefined;
  const errorId = `${id}-error`;

  // The input's own file list is rewritten on every change, so a form submits what is shown.
  const sync = (next: File[]) => {
    setFiles(next);
    if (input.current) {
      const transfer = new DataTransfer();
      for (const file of next) transfer.items.add(file);
      input.current.files = transfer.files;
    }
    onFilesChange?.(next);
  };

  const add = (incoming: File[]) => {
    const refusals: Refusal[] = [];
    const ok: File[] = [];
    for (const file of incoming) {
      if (!matches(file, accept)) refusals.push({ name: file.name, reason: "type not accepted" });
      else if (maxSize !== undefined && file.size > maxSize) refusals.push({ name: file.name, reason: `over ${formatSize(maxSize)}` });
      else ok.push(file);
    }
    let next = files;
    if (multiple) {
      next = files.filter((f) => !ok.some((o) => o.name === f.name));
      for (const file of ok) {
        if (listed.length + next.length < limit) next.push(file);
        else refusals.push({ name: file.name, reason: `only ${limit} files allowed` });
      }
    } else if (ok.length > 0) {
      next = [ok[0]];
      setListed([]);
      for (const file of ok.slice(1)) refusals.push({ name: file.name, reason: "only one file allowed" });
    }
    setRefused(refusals);
    sync(next);
  };

  const afterRemove = () => {
    setRefused([]);
    input.current?.focus();
  };

  const onDragEnter = (event: DragEvent<HTMLLabelElement>) => {
    if (disabled || !event.dataTransfer.types.includes("Files")) return;
    event.preventDefault();
    depth.current += 1;
    setDragging(true);
  };

  const onDragOver = (event: DragEvent<HTMLLabelElement>) => {
    if (disabled || !event.dataTransfer.types.includes("Files")) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = "copy";
  };

  // Entering a child fires leave on the parent first, so a depth count decides when it is really gone.
  const onDragLeave = () => {
    depth.current = Math.max(0, depth.current - 1);
    if (depth.current === 0) setDragging(false);
  };

  const onDrop = (event: DragEvent<HTMLLabelElement>) => {
    depth.current = 0;
    setDragging(false);
    if (disabled) return;
    event.preventDefault();
    add(Array.from(event.dataTransfer.files));
  };

  const rows = [
    ...listed.map((f) => ({
      key: `listed-${f.name}`,
      name: f.name,
      size: f.size,
      remove: () => {
        setListed((all) => all.filter((x) => x !== f));
        afterRemove();
      },
    })),
    ...files.map((f, i) => ({
      key: `file-${i}-${f.name}`,
      name: f.name,
      size: f.size,
      remove: () => {
        sync(files.filter((x) => x !== f));
        afterRemove();
      },
    })),
  ];

  return (
    <div
      {...rest}
      className={["eq-file-drop", className].filter(Boolean).join(" ")}
      data-dragging={dragging || undefined}
      data-disabled={disabled || undefined}
    >
      <span className="eq-file-drop-label" id={labelId}>
        {label}
      </span>
      <label className="eq-file-drop-zone" onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
        <input
          ref={input}
          type="file"
          className="eq-file-drop-input"
          name={name}
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          aria-labelledby={labelId}
          aria-describedby={[hintId, refused.length > 0 ? errorId : undefined].filter(Boolean).join(" ") || undefined}
          onChange={(event) => add(Array.from(event.target.files ?? []))}
          // Space opens the picker natively everywhere; Enter does not in every engine.
          onKeyDown={(event) => {
            if (event.key !== "Enter") return;
            event.preventDefault();
            event.currentTarget.click();
          }}
        />
        <svg className="eq-file-drop-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M10 13V3.75M6.25 7.5 10 3.75l3.75 3.75M3.75 12.5v2.25c0 .83.67 1.5 1.5 1.5h9.5c.83 0 1.5-.67 1.5-1.5V12.5" />
        </svg>
        <span className="eq-file-drop-text">
          <span className="eq-file-drop-cta">
            Drop {multiple ? "files" : "a file"} or <span className="eq-file-drop-browse">browse</span>
          </span>
          {present(hint) && (
            <span className="eq-file-drop-hint" id={hintId}>
              {hint}
            </span>
          )}
        </span>
      </label>
      <p className="eq-file-drop-error" id={errorId} aria-live="polite">
        {refused.length > 0 && `Not added: ${refused.map((r) => `${r.name} (${r.reason})`).join(", ")}.`}
      </p>
      {rows.length > 0 && (
        <ul className="eq-file-drop-list" aria-label="Chosen files">
          {rows.map((row) => {
            const value = progress?.[row.name];
            return (
              <li key={row.key} className="eq-file-drop-file">
                <svg className="eq-file-drop-file-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M9.25 1.75H4.5c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h7c.69 0 1.25-.56 1.25-1.25V5.25l-3.5-3.5Z" />
                  <path d="M9.25 1.75v3.5h3.5" />
                </svg>
                <Truncate className="eq-file-drop-name" keep={8}>
                  {row.name}
                </Truncate>
                <span className="eq-file-drop-size">{formatSize(row.size)}</span>
                <Button
                  variant="quiet"
                  size="xs"
                  square
                  className="eq-file-drop-remove"
                  aria-label={`Remove ${row.name}`}
                  disabled={disabled}
                  onClick={row.remove}
                  lead={
                    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
                      <path d="m4.5 4.5 7 7m0-7-7 7" />
                    </svg>
                  }
                />
                {value !== undefined && (
                  <progress className="eq-file-drop-progress" max={1} value={Math.max(0, Math.min(1, value))} aria-label={`Uploading ${row.name}`} />
                )}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}
components/equip/file-drop.css183 lines
@layer equip {
  .eq-file-drop {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    min-width: 0;
    font-family: var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-file-drop-label {
    width: fit-content;
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-file-drop-zone {
    position: relative;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: var(--eq-space-3);
    min-height: var(--eq-h-l);
    padding: var(--eq-space-2) var(--eq-space-3);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  /* A file held over the zone lifts it: dashed ink edge, tinted floor. */
  .eq-file-drop[data-dragging] .eq-file-drop-zone {
    border-style: dashed;
    border-color: var(--eq-ink);
    background: color-mix(in srgb, var(--eq-raised), var(--eq-ink) 6%);
    scale: 1.02;
  }

  @media (hover: hover) {
    .eq-file-drop:not([data-disabled]) .eq-file-drop-zone:hover {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
      background: color-mix(in srgb, var(--eq-raised), var(--eq-ink) 3%);
    }
  }

  .eq-file-drop:not([data-disabled], [data-dragging]) .eq-file-drop-zone:active {
    scale: var(--eq-press);
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  .eq-file-drop-zone:has(.eq-file-drop-input:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-file-drop[data-disabled] .eq-file-drop-zone {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* The real file input: focusable and announced, drawn by the zone around it. */
  .eq-file-drop-input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    opacity: 0;
  }

  .eq-file-drop-icon {
    flex: none;
    width: 20px;
    height: 20px;
    color: var(--eq-ink-2);
  }

  .eq-file-drop-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
  }

  .eq-file-drop-cta {
    font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-file-drop-browse {
    font-weight: var(--eq-weight-strong);
    text-decoration: underline;
    text-underline-offset: 2px;
  }

  .eq-file-drop-hint {
    font: var(--eq-weight) var(--eq-text-xs) / 1.35 var(--eq-font);
    color: var(--eq-ink-2);
  }

  .eq-file-drop-error {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
    color: var(--eq-danger);
  }

  /* Empty, the live region stays in the page but takes no room and no gap. */
  .eq-file-drop-error:empty {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }

  .eq-file-drop-list {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    margin: var(--eq-space-1) 0 0;
    padding: 0;
    list-style: none;
  }

  .eq-file-drop-file {
    box-sizing: border-box;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    align-items: center;
    column-gap: var(--eq-space-2);
    min-height: var(--eq-h-s);
    padding: 1px 1px 1px var(--eq-space-2);
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-s);
    font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-file-drop-file-icon {
    width: 16px;
    height: 16px;
    color: var(--eq-ink-3);
  }

  .eq-file-drop-name {
    min-width: 0;
  }

  .eq-file-drop-size {
    font-size: var(--eq-text-xs);
    font-variant-numeric: tabular-nums;
    color: var(--eq-ink-3);
    white-space: nowrap;
  }

  .eq-file-drop-progress {
    grid-column: 1 / -1;
    display: block;
    width: calc(100% - var(--eq-space-2));
    height: 2px;
    margin: 0 0 var(--eq-space-1);
    border: 0;
    border-radius: var(--eq-radius-full);
    overflow: hidden;
    appearance: none;
    background: var(--eq-tint-strong);
  }

  .eq-file-drop-progress::-webkit-progress-bar {
    background: transparent;
  }

  .eq-file-drop-progress::-webkit-progress-value {
    background: var(--eq-solid);
    transition: width var(--eq-dur-move) var(--eq-ease);
  }

  .eq-file-drop-progress::-moz-progress-bar {
    background: var(--eq-solid);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index