Skip to content
Dethink Components

ComponentsFile Upload

File Upload

Keep filenames in focus. Add files, review the batch, and recover without starting over.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files
  • Lucide (lucide-react)

    React SVG icons used in component controls and status indicators.

  • React Aria Components (react-aria-components)

    Accessible interaction primitives, including keyboard behavior, focus handling, and ARIA semantics.

Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

Import the component into your page or component file.

Usage
import { FileUpload } from "@dethink/components";

A compact intake strip, readable file rows, and progress right beneath the filename.

Project documents

Drop a mixed batch, review accepted files, and upload. Simulate a failure to try recovery without restarting the batch.

PROJECT / DOCUMENTS
Local demo

Project files

Bring the brief, notes, and references together. Review your files before uploading.

Drop files here

PDF, DOCX, PNG, JPG · Max 20 MiB each · Up to 10 files

Simulated uploads. Files stay in your browser.

Show sourceexamples/file-upload/workspace.tsx
examples/file-upload/workspace.tsx
"use client";

import { useRef, useState } from "react";
import {
  Button,
  FileUpload,
  type FileUploadHandler,
} from "@dethink/components";
import { FileUp, ShieldCheck } from "lucide-react";

function waitForTick(signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const cancel = () => {
      clearTimeout(timer);
      reject(new DOMException("Cancelled", "AbortError"));
    };
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", cancel);
      resolve();
    }, 200);
    if (signal.aborted) cancel();
    else signal.addEventListener("abort", cancel, { once: true });
  });
}

export function FileUploadWorkspace() {
  const failNext = useRef(false);
  const [armed, setArmed] = useState(false);
  const upload: FileUploadHandler = async (_file, { signal, onProgress }) => {
    const fail = failNext.current;
    if (fail) {
      failNext.current = false;
      setArmed(false);
    }
    for (let progress = 0; progress <= 100; progress += 5) {
      await waitForTick(signal);
      onProgress(progress);
      if (fail && progress >= 40)
        throw new Error("Connection interrupted. Retry this file.");
    }
  };
  return (
    <div className="bg-background text-foreground border-border mx-auto w-full max-w-3xl overflow-hidden rounded-xl border">
      <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-3">
        <div className="text-muted-foreground flex items-center gap-2 text-xs">
          <FileUp className="size-3.5" aria-hidden="true" />
          <span>PROJECT / DOCUMENTS</span>
        </div>
        <span className="text-muted-foreground text-xs">Local demo</span>
      </div>
      <div className="p-5 sm:p-7">
        <FileUpload
          label="Project files"
          description="Bring the brief, notes, and references together. Review your files before uploading."
          multiple
          accept=".pdf,.docx,.png,.jpg"
          maxFiles={10}
          maxFileSize={20 * 1024 * 1024}
          onUpload={upload}
        />
      </div>
      <div className="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3">
        <p className="text-muted-foreground flex items-center gap-2 text-xs">
          <ShieldCheck aria-hidden="true" className="size-3.5" />
          Simulated uploads. Files stay in your browser.
        </p>
        <Button
          variant="link"
          size="xs"
          disabled={armed}
          onClick={() => {
            failNext.current = true;
            setArmed(true);
          }}
        >
          {armed ? "Next upload will fail once" : "Make next upload fail"}
        </Button>
      </div>
    </div>
  );
}

export function FileUploadSingle() {
  return (
    <div className="mx-auto w-full max-w-lg">
      <FileUpload
        label="Signed agreement"
        description="Choose one PDF. An invalid replacement keeps your current document."
        accept=".pdf"
        maxFileSize={5 * 1024 * 1024}
        onUpload={async (_file, { signal, onProgress }) => {
          for (let value = 0; value <= 100; value += 10) {
            await waitForTick(signal);
            onProgress(value);
          }
        }}
      />
      <p className="text-muted-foreground mt-4 text-xs">
        Local simulation · no files leave your browser.
      </p>
    </div>
  );
}

export function FileUploadAutomatic() {
  return (
    <div className="mx-auto w-full max-w-lg">
      <FileUpload
        label="Image attachments"
        description="Accepted images start immediately. Cancel and retry each file independently."
        multiple
        accept="image/png,image/jpeg,image/webp"
        maxFiles={5}
        maxFileSize={8 * 1024 * 1024}
        autoUpload
        onUpload={async (_file, { signal }) => {
          for (let i = 0; i < 12; i++) await waitForTick(signal);
        }}
      />
      <p className="text-muted-foreground mt-4 text-xs">
        Local simulation with unknown progress · no files leave your browser.
      </p>
    </div>
  );
}

Single file

Replace a document without losing it when the new selection is invalid.

Signed agreement

Choose one PDF. An invalid replacement keeps your current document.

Drop a file here

PDF · Max 5 MiB each · One file

Local simulation · no files leave your browser.

Show sourceexamples/file-upload/workspace.tsx
examples/file-upload/workspace.tsx
"use client";

import { useRef, useState } from "react";
import {
  Button,
  FileUpload,
  type FileUploadHandler,
} from "@dethink/components";
import { FileUp, ShieldCheck } from "lucide-react";

function waitForTick(signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const cancel = () => {
      clearTimeout(timer);
      reject(new DOMException("Cancelled", "AbortError"));
    };
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", cancel);
      resolve();
    }, 200);
    if (signal.aborted) cancel();
    else signal.addEventListener("abort", cancel, { once: true });
  });
}

export function FileUploadWorkspace() {
  const failNext = useRef(false);
  const [armed, setArmed] = useState(false);
  const upload: FileUploadHandler = async (_file, { signal, onProgress }) => {
    const fail = failNext.current;
    if (fail) {
      failNext.current = false;
      setArmed(false);
    }
    for (let progress = 0; progress <= 100; progress += 5) {
      await waitForTick(signal);
      onProgress(progress);
      if (fail && progress >= 40)
        throw new Error("Connection interrupted. Retry this file.");
    }
  };
  return (
    <div className="bg-background text-foreground border-border mx-auto w-full max-w-3xl overflow-hidden rounded-xl border">
      <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-3">
        <div className="text-muted-foreground flex items-center gap-2 text-xs">
          <FileUp className="size-3.5" aria-hidden="true" />
          <span>PROJECT / DOCUMENTS</span>
        </div>
        <span className="text-muted-foreground text-xs">Local demo</span>
      </div>
      <div className="p-5 sm:p-7">
        <FileUpload
          label="Project files"
          description="Bring the brief, notes, and references together. Review your files before uploading."
          multiple
          accept=".pdf,.docx,.png,.jpg"
          maxFiles={10}
          maxFileSize={20 * 1024 * 1024}
          onUpload={upload}
        />
      </div>
      <div className="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3">
        <p className="text-muted-foreground flex items-center gap-2 text-xs">
          <ShieldCheck aria-hidden="true" className="size-3.5" />
          Simulated uploads. Files stay in your browser.
        </p>
        <Button
          variant="link"
          size="xs"
          disabled={armed}
          onClick={() => {
            failNext.current = true;
            setArmed(true);
          }}
        >
          {armed ? "Next upload will fail once" : "Make next upload fail"}
        </Button>
      </div>
    </div>
  );
}

export function FileUploadSingle() {
  return (
    <div className="mx-auto w-full max-w-lg">
      <FileUpload
        label="Signed agreement"
        description="Choose one PDF. An invalid replacement keeps your current document."
        accept=".pdf"
        maxFileSize={5 * 1024 * 1024}
        onUpload={async (_file, { signal, onProgress }) => {
          for (let value = 0; value <= 100; value += 10) {
            await waitForTick(signal);
            onProgress(value);
          }
        }}
      />
      <p className="text-muted-foreground mt-4 text-xs">
        Local simulation · no files leave your browser.
      </p>
    </div>
  );
}

export function FileUploadAutomatic() {
  return (
    <div className="mx-auto w-full max-w-lg">
      <FileUpload
        label="Image attachments"
        description="Accepted images start immediately. Cancel and retry each file independently."
        multiple
        accept="image/png,image/jpeg,image/webp"
        maxFiles={5}
        maxFileSize={8 * 1024 * 1024}
        autoUpload
        onUpload={async (_file, { signal }) => {
          for (let i = 0; i < 12; i++) await waitForTick(signal);
        }}
      />
      <p className="text-muted-foreground mt-4 text-xs">
        Local simulation with unknown progress · no files leave your browser.
      </p>
    </div>
  );
}

Selection only

Use the queue as a staging field when the host owns submission.

Supporting documents

Add your files. Review and remove them before continuing.

Drop files here

PDF, DOCX, PNG, JPG · Max 20 MiB each · Up to 10 files

Show sourceexamples/file-upload/basic.tsx
examples/file-upload/basic.tsx
"use client";
import { FileUpload } from "@dethink/components";
export function FileUploadBasic() {
  return (
    <FileUpload
      label="Supporting documents"
      description="Add your files. Review and remove them before continuing."
      multiple
      accept=".pdf,.docx,.png,.jpg"
      maxFiles={10}
      maxFileSize={20 * 1024 * 1024}
      className="mx-auto w-full max-w-xl"
    />
  );
}

Automatic attachments

Opt into immediate uploads and show honest indeterminate feedback when percent is unavailable.

Image attachments

Accepted images start immediately. Cancel and retry each file independently.

Drop files here

image/png, image/jpeg, image/webp · Max 8 MiB each · Up to 5 files

Local simulation with unknown progress · no files leave your browser.

Show sourceexamples/file-upload/workspace.tsx
examples/file-upload/workspace.tsx
"use client";

import { useRef, useState } from "react";
import {
  Button,
  FileUpload,
  type FileUploadHandler,
} from "@dethink/components";
import { FileUp, ShieldCheck } from "lucide-react";

function waitForTick(signal: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const cancel = () => {
      clearTimeout(timer);
      reject(new DOMException("Cancelled", "AbortError"));
    };
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", cancel);
      resolve();
    }, 200);
    if (signal.aborted) cancel();
    else signal.addEventListener("abort", cancel, { once: true });
  });
}

export function FileUploadWorkspace() {
  const failNext = useRef(false);
  const [armed, setArmed] = useState(false);
  const upload: FileUploadHandler = async (_file, { signal, onProgress }) => {
    const fail = failNext.current;
    if (fail) {
      failNext.current = false;
      setArmed(false);
    }
    for (let progress = 0; progress <= 100; progress += 5) {
      await waitForTick(signal);
      onProgress(progress);
      if (fail && progress >= 40)
        throw new Error("Connection interrupted. Retry this file.");
    }
  };
  return (
    <div className="bg-background text-foreground border-border mx-auto w-full max-w-3xl overflow-hidden rounded-xl border">
      <div className="border-border flex items-center justify-between gap-4 border-b px-5 py-3">
        <div className="text-muted-foreground flex items-center gap-2 text-xs">
          <FileUp className="size-3.5" aria-hidden="true" />
          <span>PROJECT / DOCUMENTS</span>
        </div>
        <span className="text-muted-foreground text-xs">Local demo</span>
      </div>
      <div className="p-5 sm:p-7">
        <FileUpload
          label="Project files"
          description="Bring the brief, notes, and references together. Review your files before uploading."
          multiple
          accept=".pdf,.docx,.png,.jpg"
          maxFiles={10}
          maxFileSize={20 * 1024 * 1024}
          onUpload={upload}
        />
      </div>
      <div className="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3">
        <p className="text-muted-foreground flex items-center gap-2 text-xs">
          <ShieldCheck aria-hidden="true" className="size-3.5" />
          Simulated uploads. Files stay in your browser.
        </p>
        <Button
          variant="link"
          size="xs"
          disabled={armed}
          onClick={() => {
            failNext.current = true;
            setArmed(true);
          }}
        >
          {armed ? "Next upload will fail once" : "Make next upload fail"}
        </Button>
      </div>
    </div>
  );
}

export function FileUploadSingle() {
  return (
    <div className="mx-auto w-full max-w-lg">
      <FileUpload
        label="Signed agreement"
        description="Choose one PDF. An invalid replacement keeps your current document."
        accept=".pdf"
        maxFileSize={5 * 1024 * 1024}
        onUpload={async (_file, { signal, onProgress }) => {
          for (let value = 0; value <= 100; value += 10) {
            await waitForTick(signal);
            onProgress(value);
          }
        }}
      />
      <p className="text-muted-foreground mt-4 text-xs">
        Local simulation · no files leave your browser.
      </p>
    </div>
  );
}

export function FileUploadAutomatic() {
  return (
    <div className="mx-auto w-full max-w-lg">
      <FileUpload
        label="Image attachments"
        description="Accepted images start immediately. Cancel and retry each file independently."
        multiple
        accept="image/png,image/jpeg,image/webp"
        maxFiles={5}
        maxFileSize={8 * 1024 * 1024}
        autoUpload
        onUpload={async (_file, { signal }) => {
          for (let i = 0; i < 12; i++) await waitForTick(signal);
        }}
      />
      <p className="text-muted-foreground mt-4 text-xs">
        Local simulation with unknown progress · no files leave your browser.
      </p>
    </div>
  );
}
FileUpload props
PropWhat it doesDefault
label / descriptionstringVisible heading and optional purpose/instructions."Attachments" / —
multiplebooleanAccumulate multiple files; single mode supports explicit replacement.false
acceptstringComma-separated extensions, MIME types, or wildcards; checked for picker and drop.Unrestricted
maxFileSize / maxFilesnumberInclusive per-file byte limit and multiple-mode count limit. Labels use KiB/MiB.Unrestricted
onUpload(file, { id, signal, onProgress }) => Promise<void>Your upload transport. Resolution marks success; rejection enables retry. Honor the AbortSignal.Not set
autoUploadbooleanStart accepted files immediately when onUpload is present. Otherwise review first.false
onFilesChange(entries: readonly FileUploadEntry[]) => voidNotification of local selection and transfer states. Not a controlled value setter.Not set
disabledbooleanBlocks new selection, removal and starting uploads. Active uploads can still be cancelled.false
classNamestringCustomize the root. Layout and states use semantic tokens and data-slot hooks.Not set

Picker and drop share extension, MIME, size, count and duplicate checks. Valid files stay in the queue; rejected files receive individual explanations. Uploads run three at a time. Report real percentages through onProgress; resolving the callback marks success. Cancellation aborts its signal, and late results cannot overwrite a newer attempt. Removing an uploaded file only removes the local entry.

Choose files is always available as an alternative to dragging. Controls include the filename in their accessible name. Batch changes and terminal results are announced without reading every progress tick. Test your complete form with a keyboard and screen reader. Error text wraps, and actions remain reachable on small screens.

FileUpload does not provide a storage service. The application owns authentication, server-side content validation, upload requests and remote deletion. Client metadata and the accept attribute are usability aids. Only local raster images receive previews; object URLs are released when no longer needed. The examples simulate uploads entirely in your browser.