Skip to content

UploadQueue

Form

Actual file selection with validation, progress, cancellation, retry, and an explicit upload adapter.

Browse and drag/dropSequential queue / individual uploadProgress and cancellationFailure / retry / limits
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Select or drop File objects, then upload individual items or process the queue serially. Provide upload(file, {signal, onProgress}) to connect storage or your API. Progress is clamped below 100 until the adapter resolves; rejection keeps a retryable row. Cancel aborts the active adapter and ignores subsequent progress or success. Duplicate file fingerprints, accept rules, queue limits, and size limits are checked before admission. Client validation is a convenience; enforce permissions, content type, and limits on the server as well. Without an adapter, the queue can select files but never claims they were uploaded. The example simulates transport; the Files page records demo metadata only, not file bytes.

Import
import { UploadQueue, type UploadAdapter } from "@/components/ui/upload-queue";

Shipped as editable source in src/components/ui/upload-queue.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/upload-queue.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { FileUp, File, Check, X } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface UploadContext {
  signal: AbortSignal;
  onProgress: (percent: number) => void;
}
export type UploadAdapter = (
  file: File,
  context: UploadContext,
) => Promise<void>;
export interface UploadQueueProps {
  label: string;
  upload?: UploadAdapter;
  onUploaded?: (file: File) => void;
  accept?: string;
  maxFiles?: number;
  maxSize?: number;
  disabled?: boolean;
  className?: string;
}
type UploadState = "queued" | "uploading" | "done" | "failed" | "cancelled";
interface QueueItem {
  id: string;
  file: File;
  status: UploadState;
  progress: number;
  error?: string;
}
export function acceptsUpload(file: Pick<File, "name" | "type">, accept = "") {
  const rules = accept
    .split(",")
    .map((rule) => rule.trim().toLowerCase())
    .filter(Boolean);
  return (
    !rules.length ||
    rules.some((rule) =>
      rule.startsWith(".")
        ? file.name.toLowerCase().endsWith(rule)
        : rule.endsWith("/*")
          ? file.type.toLowerCase().startsWith(rule.slice(0, -1))
          : file.type.toLowerCase() === rule,
    )
  );
}
export function UploadQueue({
  label,
  upload,
  onUploaded,
  accept,
  maxFiles = 5,
  maxSize = 10 * 1024 * 1024,
  disabled,
  className,
}: UploadQueueProps) {
  const id = useId();
  const sequence = useRef(0);
  const input = useRef<HTMLInputElement>(null);
  const controllers = useRef(new Map<string, AbortController>());
  const [items, setItems] = useState<QueueItem[]>([]);
  const [errors, setErrors] = useState<string[]>([]);
  const [dragging, setDragging] = useState(false);
  const [running, setRunning] = useState(false);
  const batch = useRef(false);
  useEffect(() => {
    const active = controllers.current;
    return () => {
      active.forEach((controller) => controller.abort());
      active.clear();
      batch.current = false;
    };
  }, []);
  const update = (itemId: string, patch: Partial<QueueItem>) =>
    setItems((previous) =>
      previous.map((item) =>
        item.id === itemId ? { ...item, ...patch } : item,
      ),
    );
  const add = (files: File[]) => {
    if (disabled) return;
    const messages: string[] = [];
    const incoming: QueueItem[] = [];
    const keys = new Set(
      items.map(
        (item) =>
          `${item.file.name}:${item.file.size}:${item.file.lastModified}`,
      ),
    );
    for (const file of files) {
      const key = `${file.name}:${file.size}:${file.lastModified}`;
      if (keys.has(key)) {
        messages.push(`${file.name}: already in this queue.`);
        continue;
      }
      if (!acceptsUpload(file, accept)) {
        messages.push(`${file.name}: file type is not allowed.`);
        continue;
      }
      if (file.size > maxSize) {
        messages.push(`${file.name}: exceeds the file size limit.`);
        continue;
      }
      if (items.length + incoming.length >= Math.max(0, maxFiles)) {
        messages.push(`${file.name}: queue is full.`);
        continue;
      }
      keys.add(key);
      incoming.push({
        id: `${id}-${sequence.current++}`,
        file,
        status: "queued",
        progress: 0,
      });
    }
    setItems((previous) => [...previous, ...incoming]);
    setErrors(messages);
  };
  const start = async (item: QueueItem) => {
    if (
      !upload ||
      disabled ||
      controllers.current.has(item.id) ||
      item.status === "done"
    )
      return;
    const controller = new AbortController();
    controllers.current.set(item.id, controller);
    const current = () =>
      controllers.current.get(item.id) === controller &&
      !controller.signal.aborted;
    update(item.id, { status: "uploading", progress: 0, error: undefined });
    try {
      await new Promise<void>((resolve, reject) => {
        const abort = () => reject(new DOMException("Aborted", "AbortError"));
        controller.signal.addEventListener("abort", abort, { once: true });
        Promise.resolve()
          .then(() => {
            controller.signal.throwIfAborted();
            return upload(item.file, {
              signal: controller.signal,
              onProgress: (percent) => {
                if (current() && Number.isFinite(percent))
                  update(item.id, {
                    progress: Math.max(0, Math.min(99, percent)),
                  });
              },
            });
          })
          .then(resolve, reject)
          .finally(() => controller.signal.removeEventListener("abort", abort));
      });
      if (current()) {
        onUploaded?.(item.file);
        update(item.id, { status: "done", progress: 100 });
      }
    } catch {
      if (current())
        update(item.id, {
          status: "failed",
          error: "Upload failed. Your file is kept here for retry.",
        });
    } finally {
      if (controllers.current.get(item.id) === controller)
        controllers.current.delete(item.id);
    }
  };
  const cancel = (item: QueueItem) => {
    const controller = controllers.current.get(item.id);
    controllers.current.delete(item.id);
    controller?.abort();
    update(item.id, { status: "cancelled", error: undefined });
  };
  const pending = items.filter(
    (item) =>
      item.status === "queued" ||
      item.status === "failed" ||
      item.status === "cancelled",
  );
  const startAll = async () => {
    if (batch.current || disabled) return;
    batch.current = true;
    setRunning(true);
    try {
      for (const item of pending) {
        if (!batch.current) break;
        await start(item);
      }
    } finally {
      if (batch.current) {
        batch.current = false;
        setRunning(false);
      }
    }
  };
  return (
    <section
      aria-label={label}
      className={cn(
        "min-w-0 space-y-4 rounded-xl border bg-card p-4 sm:p-5",
        className,
      )}
    >
      <div className="flex items-start justify-between gap-3">
        <div>
          <h3 className="text-sm font-semibold">{label}</h3>
          <p className="mt-1 text-xs text-muted-foreground">
            Up to {maxFiles} files ·{" "}
            {(maxSize / 1024 / 1024).toLocaleString("en-US", {
              maximumFractionDigits: 1,
            })}{" "}
            MB each
          </p>
        </div>
        <span className="rounded-full bg-muted px-2 py-1 text-xs tabular-nums">
          {items.length}/{maxFiles}
        </span>
      </div>
      <div
        onDragOver={(event) => {
          event.preventDefault();
          if (!disabled) setDragging(true);
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(event) => {
          event.preventDefault();
          setDragging(false);
          add([...event.dataTransfer.files]);
        }}
        className={cn(
          "rounded-xl border-2 border-dashed p-6 text-center",
          dragging
            ? "border-primary bg-primary/5"
            : "border-border bg-muted/20",
        )}
      >
        <FileUp aria-hidden className="mx-auto mb-2 size-7 text-primary" />
        <p className="text-sm font-medium">Drop your files here</p>
        <p className="mt-1 text-xs text-muted-foreground">
          {accept || "Any file type"}
        </p>
        <input
          ref={input}
          id={id}
          type="file"
          multiple
          accept={accept}
          disabled={disabled}
          className="sr-only"
          aria-label={`Choose files for ${label}`}
          onChange={(event) => {
            add([...(event.target.files ?? [])]);
            event.target.value = "";
          }}
        />
        <Button
          type="button"
          variant="outline"
          size="sm"
          className="mt-3"
          disabled={disabled || items.length >= maxFiles}
          onClick={() => input.current?.click()}
        >
          Browse files
        </Button>
      </div>
      {!!errors.length && (
        <div role="alert" className="space-y-1 text-sm text-destructive">
          {errors.map((message, index) => (
            <p key={`${message}-${index}`}>{message}</p>
          ))}
        </div>
      )}
      {!items.length ? (
        <p className="py-2 text-center text-xs text-muted-foreground">
          Your queue is empty. Add files to get started.
        </p>
      ) : (
        <ul className="space-y-2">
          {items.map((item) => (
            <li key={item.id} className="rounded-lg border bg-background p-3">
              <div className="flex items-center gap-2">
                <File
                  aria-hidden
                  className="size-4 shrink-0 text-muted-foreground"
                />
                <span
                  className="min-w-0 flex-1 truncate text-sm font-medium"
                  title={item.file.name}
                >
                  {item.file.name}
                </span>
                <span className="shrink-0 text-xs text-muted-foreground">
                  {Math.max(1, Math.round(item.file.size / 1024))} KB
                </span>
                {item.status === "done" && (
                  <Check
                    aria-label="Uploaded"
                    className="size-4 text-primary"
                  />
                )}
              </div>
              <div className="mt-2 flex flex-wrap items-center gap-2">
                <span className="flex-1 text-xs capitalize text-muted-foreground">
                  {item.status === "uploading"
                    ? `Uploading ${Math.round(item.progress)}%`
                    : item.status}
                </span>
                {item.status === "uploading" ? (
                  <Button
                    type="button"
                    size="sm"
                    variant="ghost"
                    aria-label={`Cancel ${item.file.name}`}
                    onClick={() => cancel(item)}
                  >
                    Cancel
                  </Button>
                ) : (
                  item.status !== "done" && (
                    <Button
                      type="button"
                      size="sm"
                      variant="ghost"
                      disabled={disabled || !upload || running}
                      aria-label={`${item.status === "queued" ? "Upload" : "Retry"} ${item.file.name}`}
                      onClick={() => void start(item)}
                    >
                      {item.status === "queued" ? "Upload" : "Retry"}
                    </Button>
                  )
                )}
                {item.status !== "uploading" && (
                  <button
                    type="button"
                    disabled={disabled || running}
                    aria-label={`Remove ${item.file.name}`}
                    className="rounded p-1 text-muted-foreground hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
                    onClick={() =>
                      setItems((previous) =>
                        previous.filter((entry) => entry.id !== item.id),
                      )
                    }
                  >
                    <X className="size-4" />
                  </button>
                )}
              </div>
              {item.status === "uploading" && (
                <div
                  role="progressbar"
                  aria-label={`Upload progress for ${item.file.name}`}
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuenow={Math.round(item.progress)}
                  className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
                >
                  <div
                    className="h-full bg-primary"
                    style={{ width: `${item.progress}%` }}
                  />
                </div>
              )}
              {item.error && (
                <p role="alert" className="mt-1 text-xs text-destructive">
                  {item.error}
                </p>
              )}
            </li>
          ))}
        </ul>
      )}
      <div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
        <p role="status" className="text-xs text-muted-foreground">
          {items.filter((item) => item.status === "done").length} completed ·{" "}
          {items.filter((item) => item.status === "failed").length} failed
        </p>
        <Button
          type="button"
          size="sm"
          disabled={
            disabled ||
            !upload ||
            !pending.length ||
            running ||
            items.some((item) => item.status === "uploading")
          }
          onClick={() => void startAll()}
        >
          {running ? "Uploading queue…" : "Upload queue"}
        </Button>
      </div>
      {!upload && (
        <p className="text-xs text-muted-foreground">
          Connect an upload adapter to enable uploads. Selected files have not
          been uploaded.
        </p>
      )}
    </section>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

UploadQueue props
PropType / DefaultDescription
labelstring

Default: required

Visible queue title and accessible section name.
uploadUploadAdapter

Default: —

Async transport receiving File, AbortSignal, and progress callback. Resolve only after success; honor cancellation.
onUploaded(file: File) => void

Default: —

Notify the parent after transport resolves, for metadata registration or refresh.
acceptstring

Default: any type

Comma-separated MIME, wildcard MIME, or filename extension rules.
maxFiles / maxSizenumber / number

Default: 5 / 10485760

Maximum queue rows and per-file bytes. Completed rows count until removed.
disabled / classNameboolean / string

Default: false / —

Prevent new interactions or extend layout; active uploads retain cancellation.

Build with these too