File Dropzone

Drag-drop + click upload zone with previews, accept and max-size validation. Compound and single-prop APIs.

Example

Installation

npx shadcn@latest add https://hirael.com/r/file-dropzone.json

API

<FileDropzone />

PropTypeDefault
valueFile[]
defaultValueFile[]
onValueChange((files: File[]) => void)
acceptstring
maxSizenumber
multiplebooleanfalse
disabledboolean
childrenReact.ReactNode

<FileDropzoneZone />

+ native element props
PropTypeDefault
headlinestring"Drop files here, or click to browse"
sublineReact.ReactNode
childrenReact.ReactNode

<FileDropzoneList />

+ native element props

No props of its own — forwards everything to the underlying element.

<FileDropzoneErrors />

+ native element props

No props of its own — forwards everything to the underlying element.

Component source

"use client";

import * as React from "react";
import { File as FileIcon, FileText, UploadCloud, X } from "lucide-react";

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

function formatBytes(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"] as const;
  let i = 0;
  let n = bytes;
  while (n >= 1024 && i < units.length - 1) {
    n /= 1024;
    i++;
  }
  const fixed = i === 0 ? n.toFixed(0) : n.toFixed(1);
  return `${fixed} ${units[i]}`;
}

function matchesAccept(file: File, accept?: string): boolean {
  if (!accept) return true;
  const tokens = accept
    .split(",")
    .map((t) => t.trim().toLowerCase())
    .filter(Boolean);
  if (tokens.length === 0) return true;
  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  for (const token of tokens) {
    if (token.startsWith(".")) {
      if (name.endsWith(token)) return true;
    } else if (token.endsWith("/*")) {
      const prefix = token.slice(0, -1);
      if (type.startsWith(prefix)) return true;
    } else if (token.includes("/")) {
      if (type === token) return true;
    }
  }
  return false;
}

export type FileDropzoneError = {
  file: File;
  reason: "size" | "type";
  message: string;
};

type Ctx = {
  files: File[];
  setFiles: (next: File[]) => void;
  accept?: string;
  maxSize?: number;
  multiple: boolean;
  disabled?: boolean;
  errors: FileDropzoneError[];
  setErrors: (next: FileDropzoneError[]) => void;
  addFiles: (incoming: FileList | File[]) => void;
  removeAt: (index: number) => void;
  inputRef: React.RefObject<HTMLInputElement | null>;
};

const FileDropzoneContext = React.createContext<Ctx | null>(null);

function useFileDropzone() {
  const ctx = React.useContext(FileDropzoneContext);
  if (!ctx) {
    throw new Error(
      "FileDropzone compound parts must be used inside <FileDropzone>",
    );
  }
  return ctx;
}

export type FileDropzoneProps = {
  value?: File[];
  defaultValue?: File[];
  onValueChange?: (files: File[]) => void;
  accept?: string;
  maxSize?: number;
  multiple?: boolean;
  disabled?: boolean;
  children?: React.ReactNode;
};

function FileDropzone({
  value: valueProp,
  defaultValue,
  onValueChange,
  accept,
  maxSize,
  multiple = false,
  disabled,
  children,
}: FileDropzoneProps) {
  const [internalFiles, setInternalFiles] = React.useState<File[]>(
    defaultValue ?? [],
  );
  const files = valueProp ?? internalFiles;
  const setFiles = React.useCallback(
    (next: File[]) => {
      if (valueProp === undefined) setInternalFiles(next);
      onValueChange?.(next);
    },
    [valueProp, onValueChange],
  );

  const [errors, setErrors] = React.useState<FileDropzoneError[]>([]);
  const inputRef = React.useRef<HTMLInputElement | null>(null);

  const addFiles = React.useCallback(
    (incoming: FileList | File[]) => {
      if (disabled) return;
      const list = Array.from(incoming);
      const nextErrors: FileDropzoneError[] = [];
      const accepted: File[] = [];
      for (const file of list) {
        if (!matchesAccept(file, accept)) {
          nextErrors.push({
            file,
            reason: "type",
            message: `"${file.name}" type not allowed.`,
          });
          continue;
        }
        if (maxSize !== undefined && file.size > maxSize) {
          nextErrors.push({
            file,
            reason: "size",
            message: `"${file.name}" exceeds ${formatBytes(maxSize)}.`,
          });
          continue;
        }
        accepted.push(file);
      }
      if (accepted.length > 0) {
        const merged = multiple ? [...files, ...accepted] : [accepted[0]];
        setFiles(merged);
      }
      setErrors(nextErrors);
    },
    [accept, disabled, files, maxSize, multiple, setFiles],
  );

  const removeAt = React.useCallback(
    (index: number) => {
      if (disabled) return;
      const next = files.filter((_, i) => i !== index);
      setFiles(next);
    },
    [disabled, files, setFiles],
  );

  const ctx = React.useMemo<Ctx>(
    () => ({
      files,
      setFiles,
      accept,
      maxSize,
      multiple,
      disabled,
      errors,
      setErrors,
      addFiles,
      removeAt,
      inputRef,
    }),
    [
      files,
      setFiles,
      accept,
      maxSize,
      multiple,
      disabled,
      errors,
      addFiles,
      removeAt,
    ],
  );

  return (
    <FileDropzoneContext.Provider value={ctx}>
      {children}
    </FileDropzoneContext.Provider>
  );
}

type FileDropzoneZoneProps = Omit<
  React.ComponentProps<"div">,
  "onDrop" | "onDragEnter" | "onDragLeave" | "onDragOver" | "children"
> & {
  headline?: string;
  subline?: React.ReactNode;
  children?: React.ReactNode;
};

function FileDropzoneZone({
  className,
  headline = "Drop files here, or click to browse",
  subline,
  children,
  ...props
}: FileDropzoneZoneProps) {
  const ctx = useFileDropzone();
  const [isDragging, setIsDragging] = React.useState(false);
  const dragCounter = React.useRef(0);

  const handleClick = () => {
    if (ctx.disabled) return;
    ctx.inputRef.current?.click();
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (ctx.disabled) return;
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      ctx.inputRef.current?.click();
    }
  };

  const resolvedSubline = React.useMemo(() => {
    if (subline !== undefined) return subline;
    const parts: string[] = [];
    if (ctx.accept) parts.push(ctx.accept);
    if (ctx.maxSize !== undefined)
      parts.push(`up to ${formatBytes(ctx.maxSize)}`);
    return parts.length > 0 ? parts.join(" · ") : null;
  }, [subline, ctx.accept, ctx.maxSize]);

  return (
    <div
      role="button"
      tabIndex={ctx.disabled ? -1 : 0}
      aria-disabled={ctx.disabled || undefined}
      data-slot="file-dropzone-zone"
      data-dragging={isDragging || undefined}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      onDragEnter={(e) => {
        e.preventDefault();
        e.stopPropagation();
        if (ctx.disabled) return;
        dragCounter.current += 1;
        if (dragCounter.current === 1) setIsDragging(true);
      }}
      onDragLeave={(e) => {
        e.preventDefault();
        e.stopPropagation();
        if (ctx.disabled) return;
        dragCounter.current -= 1;
        if (dragCounter.current <= 0) {
          dragCounter.current = 0;
          setIsDragging(false);
        }
      }}
      onDragOver={(e) => {
        e.preventDefault();
        e.stopPropagation();
      }}
      onDrop={(e) => {
        e.preventDefault();
        e.stopPropagation();
        dragCounter.current = 0;
        setIsDragging(false);
        if (ctx.disabled) return;
        const dropped = e.dataTransfer.files;
        if (dropped && dropped.length > 0) ctx.addFiles(dropped);
      }}
      className={cn(
        "flex min-w-0 flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border bg-background px-6 py-10 text-center transition-colors outline-none",
        "hover:border-foreground/30 hover:bg-accent/50",
        "focus-visible:ring-2 focus-visible:ring-ring",
        isDragging && "border-foreground/30 bg-accent/50",
        ctx.disabled && "cursor-not-allowed opacity-60",
        className,
      )}
      {...props}
    >
      <input
        ref={ctx.inputRef}
        type="file"
        accept={ctx.accept}
        multiple={ctx.multiple}
        disabled={ctx.disabled}
        className="sr-only"
        onChange={(e) => {
          const list = e.target.files;
          if (list && list.length > 0) ctx.addFiles(list);
          e.target.value = "";
        }}
      />
      {children ?? (
        <>
          <UploadCloud className="size-6 text-muted-foreground" aria-hidden />
          <p className="text-sm text-foreground">{headline}</p>
          {resolvedSubline && (
            <p className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
              {resolvedSubline}
            </p>
          )}
        </>
      )}
    </div>
  );
}

function iconForFile(file: File) {
  if (
    file.type.startsWith("text/") ||
    /\.(md|txt|csv|json)$/i.test(file.name)
  ) {
    return FileText;
  }
  return FileIcon;
}

type FileDropzoneListProps = React.ComponentProps<"ul">;

function FileDropzoneList({ className, ...props }: FileDropzoneListProps) {
  const ctx = useFileDropzone();
  if (ctx.files.length === 0) return null;
  return (
    <ul
      data-slot="file-dropzone-list"
      className={cn("mt-3 flex min-w-0 flex-col gap-1.5", className)}
      {...props}
    >
      {ctx.files.map((file, index) => {
        const Icon = iconForFile(file);
        return (
          <li
            key={`${file.name}-${file.lastModified}-${index}`}
            className="flex min-w-0 items-center gap-2 rounded-sm border border-border bg-card px-2.5 py-1.5 text-sm"
          >
            <Icon
              className="size-3.5 shrink-0 text-muted-foreground"
              aria-hidden
            />
            <span className="min-w-0 flex-1 truncate" title={file.name}>
              {file.name}
            </span>
            <span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
              {formatBytes(file.size)}
            </span>
            {!ctx.disabled && (
              <button
                type="button"
                aria-label={`Remove ${file.name}`}
                onClick={() => ctx.removeAt(index)}
                className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <X className="size-3" />
              </button>
            )}
          </li>
        );
      })}
    </ul>
  );
}

type FileDropzoneErrorsProps = React.ComponentProps<"ul">;

function FileDropzoneErrors({ className, ...props }: FileDropzoneErrorsProps) {
  const ctx = useFileDropzone();
  if (ctx.errors.length === 0) return null;
  return (
    <ul
      role="alert"
      data-slot="file-dropzone-errors"
      className={cn("mt-2 flex min-w-0 flex-col gap-1", className)}
      {...props}
    >
      {ctx.errors.map((err, i) => (
        <li
          key={`${err.file.name}-${i}`}
          className="break-words text-[11px] text-destructive"
        >
          {err.message}
        </li>
      ))}
    </ul>
  );
}

export { FileDropzone, FileDropzoneZone, FileDropzoneList, FileDropzoneErrors };

Dependencies

npm

lucide-react