deepmoon

File Upload

Compound API: FileUpload, FileUploadList, FileUploadItem. Progress below is simulated client-side; the component only reports accepted/rejected files.

Multiple files

Single file

Restricted (images only, max 500 KB)

Drop or pick any non-image file, or an image over 500 KB, to see the rejection state.

Disabled

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/file-upload.json

npm dependencies: lucide-react

Props

FileUploadProps
PropTypeRequired
acceptstringNo
multiplebooleanNo
maxSizenumberNo
disabledbooleanNo
onFilesAdded(accepted: File[], rejected: FileRejection[]) => voidYes
FileUploadItemProps
PropTypeRequired
itemFileUploadItemDataYes
onRemove(id: string) => voidNo

Code

file-upload.tsx
"use client";

import * as React from "react";
import { AlertCircle, File as FileIcon, ImageIcon, Upload, X } from "lucide-react";

import { cn } from "@/lib/utils";

export type FileUploadItemStatus = "pending" | "uploading" | "success" | "error";

export interface FileUploadItemData {
  id: string;
  file: File;
  status: FileUploadItemStatus;
  /** 0-100, meaningful when status is "uploading". */
  progress?: number;
  error?: string;
}

export interface FileRejection {
  file: File;
  reason: "type" | "size";
  message: string;
}

function formatBytes(bytes: number): string {
  if (bytes === 0) return "0 B";
  const units = ["B", "KB", "MB", "GB"];
  const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
  const value = bytes / 1024 ** exponent;
  return `${exponent === 0 ? value : value.toFixed(1)} ${units[exponent]}`;
}

function matchesAccept(file: File, accept?: string): boolean {
  if (!accept) return true;
  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept
    .split(",")
    .map((pattern) => pattern.trim().toLowerCase())
    .filter(Boolean)
    .some((pattern) => {
      if (pattern.startsWith(".")) return name.endsWith(pattern);
      if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
      return type === pattern;
    });
}

export interface FileUploadProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onDrop"> {
  accept?: string;
  multiple?: boolean;
  /** Max size per file, in bytes. */
  maxSize?: number;
  disabled?: boolean;
  onFilesAdded: (accepted: File[], rejected: FileRejection[]) => void;
}

/**
 * Dropzone + real, keyboard-operable file input. The visible prompt is a
 * <label> associated with a genuinely present (sr-only, not display:none)
 * <input type="file">, so Tab reaches it, Enter/Space opens the native
 * picker, and clicking anywhere in the zone works too — none of this
 * depends on drag-and-drop. Drag state is exposed via data-dragging for
 * styling and announced through an aria-live region for screen reader
 * users driving (or scripting) a drag interaction.
 */
export function FileUpload({
  accept,
  multiple = true,
  maxSize,
  disabled,
  onFilesAdded,
  className,
  children,
  id: idProp,
  ...props
}: FileUploadProps) {
  const generatedId = React.useId();
  const inputId = idProp ?? generatedId;
  const [isDragging, setIsDragging] = React.useState(false);
  const dragDepth = React.useRef(0);

  const processFiles = (fileList: FileList | null) => {
    if (!fileList || disabled) return;
    const files = Array.from(fileList);
    const candidates = multiple ? files : files.slice(0, 1);
    const accepted: File[] = [];
    const rejected: FileRejection[] = [];

    for (const file of candidates) {
      if (!matchesAccept(file, accept)) {
        rejected.push({ file, reason: "type", message: `"${file.name}" isn't an accepted file type.` });
        continue;
      }
      if (maxSize && file.size > maxSize) {
        rejected.push({
          file,
          reason: "size",
          message: `"${file.name}" is larger than ${formatBytes(maxSize)}.`,
        });
        continue;
      }
      accepted.push(file);
    }

    onFilesAdded(accepted, rejected);
  };

  return (
    <div className={cn("flex flex-col gap-3", className)} {...props}>
      <label
        htmlFor={inputId}
        data-dragging={isDragging || undefined}
        data-disabled={disabled || undefined}
        onDragEnter={(event) => {
          event.preventDefault();
          if (disabled) return;
          dragDepth.current += 1;
          setIsDragging(true);
        }}
        onDragLeave={(event) => {
          event.preventDefault();
          dragDepth.current = Math.max(0, dragDepth.current - 1);
          if (dragDepth.current === 0) setIsDragging(false);
        }}
        onDragOver={(event) => event.preventDefault()}
        onDrop={(event) => {
          event.preventDefault();
          dragDepth.current = 0;
          setIsDragging(false);
          if (!disabled) processFiles(event.dataTransfer.files);
        }}
        className={cn(
          "flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 p-8 text-center transition-colors",
          "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background",
          !disabled && "cursor-pointer hover:border-primary/50 hover:bg-muted/70",
          isDragging && "border-primary bg-primary/5",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {children ?? (
          <>
            <Upload className="size-8 text-muted-foreground" aria-hidden="true" />
            <p className="text-sm font-medium text-foreground">Drag files here or click to browse</p>
            {accept ? <p className="text-xs text-muted-foreground">Accepted: {accept}</p> : null}
          </>
        )}
        <input
          id={inputId}
          type="file"
          className="sr-only"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          onChange={(event) => {
            processFiles(event.target.files);
            event.target.value = "";
          }}
        />
      </label>
      <span aria-live="polite" className="sr-only">
        {isDragging ? "Drop files here to upload." : ""}
      </span>
    </div>
  );
}

export interface FileUploadListProps extends React.ComponentPropsWithoutRef<"ul"> {}

export function FileUploadList({ className, ...props }: FileUploadListProps) {
  return <ul role="list" className={cn("flex list-none flex-col gap-2", className)} {...props} />;
}

export interface FileUploadItemProps extends React.ComponentPropsWithoutRef<"li"> {
  item: FileUploadItemData;
  onRemove?: (id: string) => void;
}

export function FileUploadItem({ item, onRemove, className, ...props }: FileUploadItemProps) {
  const Icon = item.file.type.startsWith("image/") ? ImageIcon : FileIcon;

  return (
    <li
      className={cn("flex items-center gap-3 rounded-md border border-border bg-card p-3", className)}
      {...props}
    >
      <Icon className="size-8 shrink-0 text-muted-foreground" aria-hidden="true" />
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <div className="flex items-center justify-between gap-2">
          <span className="truncate text-sm font-medium text-foreground">{item.file.name}</span>
          <span className="shrink-0 text-xs text-muted-foreground">{formatBytes(item.file.size)}</span>
        </div>
        {item.status === "uploading" ? (
          <div
            role="progressbar"
            aria-valuenow={Math.round(item.progress ?? 0)}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-label={`Uploading ${item.file.name}`}
            className="h-1.5 w-full overflow-hidden rounded-full bg-muted"
          >
            <div
              className="h-full rounded-full bg-primary transition-[width]"
              style={{ width: `${Math.round(item.progress ?? 0)}%` }}
            />
          </div>
        ) : item.status === "error" ? (
          <p className="flex items-center gap-1 text-xs text-destructive">
            <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
            {item.error ?? "Upload failed."}
          </p>
        ) : null}
      </div>
      {onRemove ? (
        <button
          type="button"
          onClick={() => onRemove(item.id)}
          aria-label={`Remove ${item.file.name}`}
          className={cn(
            "shrink-0 rounded-md p-1.5 text-muted-foreground outline-none transition-colors",
            "hover:bg-muted hover:text-foreground",
            "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          )}
        >
          <X className="size-4" aria-hidden="true" />
        </button>
      ) : null}
    </li>
  );
}