Bloom Logo

File Upload

Interactive drag & drop file upload zone with drop-zone highlight animations, immediate image/video thumbnail previews, image crop & rotation modal, clipboard image pasting, resolution/aspect ratio validation, and per-file progress tracking.

importimport { FileUpload } from "@/components/ui/fileUpload/fileUpload";
$npx @bloomui-react/cli add fileUpload
fileUpload.tsx
"use client";

import { Icon } from "@iconify/react";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

export const fileUploadDragVariants = cva(
  "relative flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-2xl transition-all duration-300 cursor-pointer select-none group",
  {
    variants: {
      variant: {
        default:
          "bg-white dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50/50 dark:hover:bg-zinc-900/30",
        bordered:
          "bg-transparent border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50/30 dark:hover:bg-zinc-900/10",
        flat: "bg-zinc-100 dark:bg-zinc-800/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800",
        underlined:
          "bg-transparent border-t-0 border-x-0 border-b-2 border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50/10 rounded-none",
        filled:
          "bg-zinc-100 dark:bg-zinc-800/80 border-transparent hover:bg-zinc-200/50 dark:hover:bg-zinc-800/40",
        glassmorphism:
          "backdrop-blur-md bg-white/10 dark:bg-black/10 border-white/20 dark:border-white/10 hover:bg-white/20 dark:hover:bg-black/20 shadow-lg",
        "gradient-border":
          "bg-white dark:bg-zinc-950 border-transparent before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:p-[1px] before:bg-gradient-to-r before:from-sky-500 before:via-indigo-500 before:to-pink-500 hover:bg-zinc-50/50 dark:hover:bg-zinc-900/30",
        glow: "bg-white dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 shadow-xs hover:shadow-[0_0_12px_rgba(14,165,233,0.15)]",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

const matchAcceptRule = (
  fileName: string,
  fileType: string,
  accept: string,
) => {
  const rules = accept.split(",").map((r) => r.trim().toLowerCase());
  return rules.some((rule) => {
    if (rule.startsWith(".")) {
      return fileName.toLowerCase().endsWith(rule);
    }
    if (rule.endsWith("/*")) {
      const baseType = rule.replace("/*", "");
      return fileType.toLowerCase().startsWith(baseType);
    }
    return fileType.toLowerCase() === rule;
  });
};

export interface FileItemState {
  id: string;
  file: File;
  previewUrl?: string;
  progress: number;
  status: "uploading" | "paused" | "completed" | "error";
  errorMessage?: string;
}

export interface FileValidationRules {
  minWidth?: number;
  minHeight?: number;
  maxWidth?: number;
  maxHeight?: number;
  aspectRatio?: number;
  aspectRatioTolerance?: number;
}

export interface FileUploadProps {
  onFilesSelected?: (files: File[]) => void;
  accept?: string;
  multiple?: boolean;
  maxSizeMB?: number;
  allowPaste?: boolean;
  enableCrop?: boolean;
  validationRules?: FileValidationRules;
  label?: React.ReactNode;
  description?: React.ReactNode;
  disabled?: boolean;
  showPreviews?: boolean;
  simulateProgress?: boolean;
  variant?:
    | "default"
    | "bordered"
    | "flat"
    | "underlined"
    | "filled"
    | "glassmorphism"
    | "gradient-border"
    | "glow";
  isRequired?: boolean;
  className?: string;
}

export function FileUpload({
  onFilesSelected,
  accept,
  multiple = false,
  maxSizeMB = 10,
  allowPaste = true,
  enableCrop = false,
  validationRules,
  label,
  description = "Drag & drop files here, paste image, or click to browse",
  disabled = false,
  showPreviews = true,
  simulateProgress = true,
  variant = "default",
  isRequired = false,
  className,
}: FileUploadProps) {
  const [dragActive, setDragActive] = React.useState(false);
  const [fileItems, setFileItems] = React.useState<FileItemState[]>([]);
  const [cropFileItem, setCropFileItem] = React.useState<FileItemState | null>(
    null,
  );
  const [cropRotation, setCropRotation] = React.useState<number>(0);
  const [cropZoom, setCropZoom] = React.useState<number>(1);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const _validateImageDimensions = (file: File): Promise<string | null> => {
    return new Promise((resolve) => {
      if (!validationRules || !file.type.startsWith("image/")) {
        resolve(null);
        return;
      }

      const img = new Image();
      const objectUrl = URL.createObjectURL(file);
      img.onload = () => {
        URL.revokeObjectURL(objectUrl);
        const { width, height } = img;
        const {
          minWidth,
          minHeight,
          maxWidth,
          maxHeight,
          aspectRatio,
          aspectRatioTolerance = 0.05,
        } = validationRules;

        if (minWidth && width < minWidth) {
          resolve(
            `Image width (${width}px) is less than min allowed ${minWidth}px.`,
          );
          return;
        }
        if (minHeight && height < minHeight) {
          resolve(
            `Image height (${height}px) is less than min allowed ${minHeight}px.`,
          );
          return;
        }
        if (maxWidth && width > maxWidth) {
          resolve(
            `Image width (${width}px) exceeds max allowed ${maxWidth}px.`,
          );
          return;
        }
        if (maxHeight && height > maxHeight) {
          resolve(
            `Image height (${height}px) exceeds max allowed ${maxHeight}px.`,
          );
          return;
        }
        if (aspectRatio) {
          const currentRatio = width / height;
          if (Math.abs(currentRatio - aspectRatio) > aspectRatioTolerance) {
            resolve(
              `Aspect ratio (${currentRatio.toFixed(2)}) does not match required ratio (${aspectRatio.toFixed(2)}).`,
            );
            return;
          }
        }
        resolve(null);
      };
      img.onerror = () => {
        URL.revokeObjectURL(objectUrl);
        resolve(null);
      };
      img.src = objectUrl;
    });
  };

  const processFiles = React.useCallback(
    async (filesList: File[]) => {
      const newItems: FileItemState[] = [];

      for (const file of filesList) {
        let errorMessage: string | undefined;

        if (file.size > maxSizeMB * 1024 * 1024) {
          errorMessage = `File size exceeds ${maxSizeMB}MB limit.`;
        } else if (accept && !matchAcceptRule(file.name, file.type, accept)) {
          errorMessage = `Invalid file type. Allowed: ${accept}`;
        } else {
          const valError = await _validateImageDimensions(file);
          if (valError) errorMessage = valError;
        }

        let previewUrl: string | undefined;
        if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
          previewUrl = URL.createObjectURL(file);
        }

        const item: FileItemState = {
          id: `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 5)}`,
          file,
          previewUrl,
          progress: errorMessage ? 0 : simulateProgress ? 0 : 100,
          status: errorMessage
            ? "error"
            : simulateProgress
              ? "uploading"
              : "completed",
          errorMessage,
        };

        newItems.push(item);
      }

      const updated = multiple ? [...fileItems, ...newItems] : newItems;
      setFileItems(updated);
      onFilesSelected?.(
        updated.filter((i) => i.status !== "error").map((item) => item.file),
      );

      if (
        enableCrop &&
        newItems.length > 0 &&
        newItems[0].file.type.startsWith("image/") &&
        !newItems[0].errorMessage
      ) {
        setCropFileItem(newItems[0]);
        setCropRotation(0);
        setCropZoom(1);
      }
    },
    [
      maxSizeMB,
      accept,
      simulateProgress,
      multiple,
      fileItems,
      onFilesSelected,
      enableCrop,
      _validateImageDimensions,
    ],
  );

  const handleFiles = (files: FileList | null) => {
    if (!files) return;
    processFiles(Array.from(files));
  };

  React.useEffect(() => {
    if (!allowPaste || disabled) return;

    const handlePaste = (e: ClipboardEvent) => {
      const items = e.clipboardData?.items;
      if (!items) return;

      const pastedFiles: File[] = [];
      for (let i = 0; i < items.length; i++) {
        if (items[i].type.startsWith("image/")) {
          const blob = items[i].getAsFile();
          if (blob) {
            const pastedFile = new File(
              [blob],
              `pasted-image-${Date.now()}.png`,
              { type: blob.type },
            );
            pastedFiles.push(pastedFile);
          }
        }
      }

      if (pastedFiles.length > 0) {
        processFiles(pastedFiles);
      }
    };

    window.addEventListener("paste", handlePaste);
    return () => window.removeEventListener("paste", handlePaste);
  }, [allowPaste, disabled, processFiles]);

  React.useEffect(() => {
    if (!simulateProgress) return;

    const interval = setInterval(() => {
      setFileItems((prev) =>
        prev.map((item) => {
          if (item.status === "uploading" && item.progress < 100) {
            const nextProgress = Math.min(
              100,
              item.progress + Math.floor(Math.random() * 25) + 10,
            );
            return {
              ...item,
              progress: nextProgress,
              status: nextProgress === 100 ? "completed" : "uploading",
            };
          }
          return item;
        }),
      );
    }, 400);

    return () => clearInterval(interval);
  }, [simulateProgress]);

  const handleDrag = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.type === "dragenter" || e.type === "dragover") {
      setDragActive(true);
    } else if (e.type === "dragleave") {
      setDragActive(false);
    }
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setDragActive(false);
    if (e.dataTransfer.files?.[0]) {
      handleFiles(e.dataTransfer.files);
    }
  };

  const removeFile = (id: string) => {
    const updated = fileItems.filter((item) => item.id !== id);
    setFileItems(updated);
    onFilesSelected?.(
      updated.filter((i) => i.status !== "error").map((item) => item.file),
    );
  };

  const togglePause = (id: string) => {
    setFileItems((prev) =>
      prev.map((item) => {
        if (item.id === id) {
          const nextStatus = item.status === "paused" ? "uploading" : "paused";
          return { ...item, status: nextStatus };
        }
        return item;
      }),
    );
  };

  const handleApplyCrop = () => {
    if (!cropFileItem) return;

    setCropFileItem(null);
  };

  const getFileIcon = (file: File) => {
    if (file.type.startsWith("image/")) return "hugeicons:image-01";
    if (file.type.startsWith("video/")) return "hugeicons:video-01";
    if (file.type.startsWith("audio/")) return "hugeicons:music-note-01";
    if (file.type.includes("pdf")) return "hugeicons:pdf-01";
    return "hugeicons:file-02";
  };

  return (
    <div className={cn("flex flex-col gap-2 w-full", className)}>
      {label && (
        <label className="text-xs font-semibold text-zinc-900 dark:text-zinc-100 select-none">
          {label}
          {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
        </label>
      )}

      <div
        onDragEnter={handleDrag}
        onDragLeave={handleDrag}
        onDragOver={handleDrag}
        onDrop={handleDrop}
        onClick={() => !disabled && inputRef.current?.click()}
        className={cn(
          fileUploadDragVariants({ variant }),
          dragActive &&
            "border-sky-500 bg-sky-500/10 dark:border-sky-400 dark:bg-sky-400/10 scale-[1.01]",
          disabled && "opacity-50 cursor-not-allowed pointer-events-none",
        )}
      >
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          onChange={(e) => handleFiles(e.target.files)}
          className="hidden"
        />
        <div className="p-3.5 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xs mb-3 text-sky-500 dark:text-sky-400 group-hover:scale-110 transition-transform duration-200">
          <Icon icon="hugeicons:cloud-upload" className="size-6" />
        </div>
        <p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 text-center">
          {description}
        </p>
        <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
          Max file size: {maxSizeMB}MB{" "}
          {allowPaste && "• Paste supported (Ctrl+V)"}
        </p>
      </div>

      {fileItems.length > 0 && (
        <div className="flex flex-col gap-2.5 mt-3">
          {fileItems.map((item) => (
            <div
              key={item.id}
              className={cn(
                "flex flex-col gap-2 p-3 bg-white dark:bg-zinc-900 border rounded-2xl text-xs shadow-xs transition-all",
                item.status === "error"
                  ? "border-rose-300 dark:border-rose-900/50 bg-rose-50/30 dark:bg-rose-950/10"
                  : "border-zinc-200 dark:border-zinc-800",
              )}
            >
              <div className="flex items-center justify-between gap-3">
                <div className="flex items-center gap-3 min-w-0">
                  {showPreviews && item.previewUrl ? (
                    <div className="size-10 rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-800 shrink-0 border border-zinc-200 dark:border-zinc-700">
                      {item.file.type.startsWith("image/") ? (
                        <img
                          src={item.previewUrl}
                          alt={item.file.name}
                          className="size-full object-cover"
                        />
                      ) : (
                        <video
                          src={item.previewUrl}
                          className="size-full object-cover"
                          aria-label={item.file.name}
                        >
                          <track kind="captions" />
                        </video>
                      )}
                    </div>
                  ) : (
                    <div className="size-10 rounded-xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center shrink-0 border border-zinc-200 dark:border-zinc-700 text-zinc-500">
                      <Icon icon={getFileIcon(item.file)} className="size-5" />
                    </div>
                  )}

                  <div className="flex flex-col min-w-0">
                    <span className="truncate font-semibold text-zinc-900 dark:text-zinc-100">
                      {item.file.name}
                    </span>
                    <span className="text-[11px] text-zinc-400">
                      {(item.file.size / (1024 * 1024)).toFixed(2)} MB •{" "}
                      <span
                        className={cn(
                          item.status === "completed" &&
                            "text-emerald-500 font-semibold",
                          item.status === "uploading" && "text-sky-500",
                          item.status === "paused" &&
                            "text-amber-500 font-semibold",
                          item.status === "error" &&
                            "text-rose-600 dark:text-rose-400 font-semibold",
                        )}
                      >
                        {item.status === "error"
                          ? (item.errorMessage ?? "Validation Error")
                          : item.status === "completed"
                            ? "Completed"
                            : item.status === "paused"
                              ? "Paused"
                              : `${item.progress}%`}
                      </span>
                    </span>
                  </div>
                </div>

                <div className="flex items-center gap-1 shrink-0">
                  {enableCrop &&
                    item.file.type.startsWith("image/") &&
                    item.status !== "error" && (
                      <button
                        type="button"
                        onClick={() => {
                          setCropFileItem(item);
                          setCropRotation(0);
                          setCropZoom(1);
                        }}
                        className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-sky-500 cursor-pointer transition-colors"
                        title="Crop & Rotate Image"
                      >
                        <Icon icon="hugeicons:crop" className="size-4" />
                      </button>
                    )}
                  {item.status !== "completed" && item.status !== "error" && (
                    <button
                      type="button"
                      onClick={() => togglePause(item.id)}
                      className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 cursor-pointer transition-colors"
                    >
                      <Icon
                        icon={
                          item.status === "paused"
                            ? "hugeicons:play"
                            : "hugeicons:pause"
                        }
                        className="size-4"
                      />
                    </button>
                  )}
                  <button
                    type="button"
                    onClick={() => removeFile(item.id)}
                    className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-rose-500 cursor-pointer transition-colors"
                  >
                    <Icon icon="hugeicons:cancel-01" className="size-4" />
                  </button>
                </div>
              </div>

              {item.status !== "completed" && item.status !== "error" && (
                <div className="w-full bg-zinc-100 dark:bg-zinc-800 h-1.5 rounded-full overflow-hidden">
                  <div
                    className={cn(
                      "h-full transition-all duration-300 rounded-full",
                      item.status === "paused" ? "bg-amber-500" : "bg-sky-500",
                    )}
                    style={{ width: `${item.progress}%` }}
                  />
                </div>
              )}
            </div>
          ))}
        </div>
      )}

      {cropFileItem && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4">
          <div className="w-full max-w-md bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 shadow-2xl flex flex-col gap-4">
            <div className="flex items-center justify-between">
              <h3 className="text-base font-bold text-zinc-900 dark:text-zinc-100">
                Crop & Rotate Image
              </h3>
              <button
                type="button"
                onClick={() => setCropFileItem(null)}
                className="p-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400"
              >
                <Icon icon="hugeicons:cancel-01" className="size-4" />
              </button>
            </div>

            <div className="relative size-64 mx-auto overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-zinc-950 flex items-center justify-center">
              {cropFileItem.previewUrl && (
                <img
                  src={cropFileItem.previewUrl}
                  alt="Crop preview"
                  className="max-h-full max-w-full object-contain transition-transform duration-200"
                  style={{
                    transform: `rotate(${cropRotation}deg) scale(${cropZoom})`,
                  }}
                />
              )}
            </div>

            <div className="flex items-center justify-between gap-4">
              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => setCropRotation((r) => (r - 90) % 360)}
                  className="p-2 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
                  title="Rotate Left"
                >
                  <Icon icon="hugeicons:rotate-left" className="size-4" />
                </button>
                <button
                  type="button"
                  onClick={() => setCropRotation((r) => (r + 90) % 360)}
                  className="p-2 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
                  title="Rotate Right"
                >
                  <Icon icon="hugeicons:rotate-right" className="size-4" />
                </button>
              </div>

              <div className="flex items-center gap-2 text-xs font-semibold text-zinc-600 dark:text-zinc-400">
                <span>Zoom</span>
                <input
                  type="range"
                  min="0.5"
                  max="2.5"
                  step="0.1"
                  value={cropZoom}
                  onChange={(e) => setCropZoom(Number(e.target.value))}
                  className="w-24 accent-sky-500"
                />
              </div>
            </div>

            <div className="flex justify-end gap-2 pt-2 border-t border-zinc-200 dark:border-zinc-800">
              <button
                type="button"
                onClick={() => setCropFileItem(null)}
                className="px-4 py-2 text-xs font-semibold rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
              >
                Cancel
              </button>
              <button
                type="button"
                onClick={handleApplyCrop}
                className="px-4 py-2 text-xs font-semibold rounded-xl bg-sky-600 hover:bg-sky-500 text-white transition-colors"
              >
                Apply Changes
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

Default

Interactive drop zone featuring live progress bars per file with pause, resume, and cancellation buttons.

simulateProgress: booleanshowPreviews: boolean

Drag and drop images, videos or documents here

Max file size: 25MB • Paste supported (Ctrl+V)

Variants

Choose between default, bordered, flat, filled, glow, glassmorphism, gradient-border, and underlined styles.

variant: default | bordered | flat | filled | glow | glassmorphism | gradient-border | underlined

Default style

Max file size: 10MB • Paste supported (Ctrl+V)

Bordered style

Max file size: 10MB • Paste supported (Ctrl+V)

Flat style

Max file size: 10MB • Paste supported (Ctrl+V)

Filled style

Max file size: 10MB • Paste supported (Ctrl+V)

Glow style

Max file size: 10MB • Paste supported (Ctrl+V)

Glassmorphism style

Max file size: 10MB • Paste supported (Ctrl+V)

Gradient border style

Max file size: 10MB • Paste supported (Ctrl+V)

Underlined style

Max file size: 10MB • Paste supported (Ctrl+V)

Thumbnails & Immediate Previews

Renders instant preview thumbnails for uploaded images, videos, and document file types.

showPreviews: booleanaccept: string

Drop an avatar image to see immediate preview

Max file size: 5MB • Paste supported (Ctrl+V)

Disabled State

Disable file upload zone interactions with the disabled prop.

disabled: boolean

Uploads are currently closed for this ticket

Max file size: 10MB • Paste supported (Ctrl+V)

Image Cropping & Rotation Preview Modal

Pass enableCrop to allow users to rotate (90° steps) and zoom/crop uploaded images inside a dedicated interactive modal.

enableCrop: boolean

Upload an image to trigger the crop & rotation preview modal

Max file size: 10MB • Paste supported (Ctrl+V)

Paste from Clipboard

Pass allowPaste to intercept global Ctrl+V image clipboard events and automatically upload captured screenshots or copied images.

allowPaste: boolean

Copy any image to clipboard and press Ctrl+V on this page

Max file size: 10MB • Paste supported (Ctrl+V)

File Size & Type Validation

Limit allowed uploads by specifying multiple comma-separated format extensions (using accept like .pdf,.docx,.xlsx) and maximum file size (using maxSizeMB). Files violating these constraints will display immediate validation error status cards.

accept: stringmaxSizeMB: number

Only PDF, DOCX or XLSX files up to 5MB are accepted.

Max file size: 5MB • Paste supported (Ctrl+V)

Resolution & Aspect Ratio Validation Rules

Pass validationRules to enforce minimum/maximum pixel dimensions or required aspect ratios (e.g. 1:1 square, 16:9 widescreen).

validationRules: FileValidationRules

Upload a 16:9 image (min 400x200px)

Max file size: 10MB • Paste supported (Ctrl+V)

Required State

Displays an asterisk next to the label indicating that uploading a file is mandatory.

isRequired: boolean

Drag & drop files here, paste image, or click to browse

Max file size: 10MB • Paste supported (Ctrl+V)

Props — FileUpload

Supported properties for the FileUpload component.

PropTypeDefaultDescription
enableCropbooleanfalseOpens interactive image crop and rotation modal before uploading.
allowPastebooleantrueAllows pasting images directly from the clipboard via Ctrl+V shortcut.
validationRulesFileValidationRulesValidation object enforcing min/max pixel resolution or aspect ratio.
showPreviewsbooleantrueRenders thumbnail previews for images and videos.
simulateProgressbooleantrueEnables individual file upload progress tracking with pause/resume controls.
maxSizeMBnumber10Maximum allowable file size limit in megabytes.