Bloom Logo

File Input

A lightweight and modular file attachment component styled to look like a standard input field, supporting visual variants, progress bars, loading indicators, size scales, and multiple selections.

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

import { Icon } from "@iconify/react";
import { cva } from "class-variance-authority";
import * as React from "react";
import { Badge } from "@/components/ui/badge/badge";
import { designRadius } from "@/lib/design-system";
import { cn } from "@/lib/utils";

export interface FileInputProps
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "size" | "onChange"
  > {
  variant?:
    | "default"
    | "bordered"
    | "flat"
    | "underlined"
    | "filled"
    | "glassmorphism"
    | "gradient-border"
    | "glow";
  size?: "sm" | "md" | "lg";
  radius?: keyof typeof designRadius;
  label?: React.ReactNode;
  description?: React.ReactNode;
  errorMessage?: React.ReactNode;
  isInvalid?: boolean;
  isLoading?: boolean;
  isClearable?: boolean;
  progress?: number;
  showBadges?: boolean;
  maxSizeMB?: number;
  onFilesSelected?: (files: File[]) => void;
  isRequired?: boolean;
}

const fileInputVariants = cva(
  "w-full transition-all flex items-center font-normal focus-within:outline-none disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none relative overflow-hidden cursor-pointer",
  {
    variants: {
      variant: {
        default:
          "bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
        bordered:
          "bg-transparent border-2 border-zinc-200 dark:border-zinc-800 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
        flat: "bg-zinc-100 dark:bg-zinc-850/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800 focus-within:bg-white dark:focus-within:bg-zinc-950 focus-within:border-sky-500 border text-zinc-900 dark:text-zinc-100",
        underlined:
          "bg-transparent border-b-2 border-zinc-200 dark:border-zinc-800 rounded-none px-0 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
        filled:
          "bg-zinc-100 dark:bg-zinc-850/80 border border-transparent focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
        glassmorphism:
          "backdrop-blur-md bg-white/10 dark:bg-black/10 border border-white/20 dark:border-white/10 focus-within:border-sky-500 shadow-lg text-zinc-900 dark:text-zinc-100",
        "gradient-border":
          "bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 relative [background-clip:padding-box] border 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 focus-within:ring-2 focus-within:ring-indigo-500/30",
        glow: "bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:shadow-[0_0_12px_rgba(14,165,233,0.35)] text-zinc-900 dark:text-zinc-100",
      },
      size: {
        sm: "h-8 px-2.5 text-xs gap-1.5",
        md: "h-10 px-3 text-sm gap-2",
        lg: "h-12 px-4 text-base gap-2.5",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "md",
    },
  },
);

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 const FileInput = React.forwardRef<HTMLInputElement, FileInputProps>(
  (
    {
      variant = "default",
      size = "md",
      radius = "xl",
      label,
      description,
      errorMessage,
      isInvalid,
      isLoading,
      isClearable = true,
      progress,
      showBadges = false,
      maxSizeMB = 10,
      onFilesSelected,
      placeholder = "No file chosen",
      multiple,
      accept,
      disabled,
      className,
      isRequired = false,
      ...props
    },
    ref,
  ) => {
    const inputRef = React.useRef<HTMLInputElement>(null);
    React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);

    const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
    const [localError, setLocalError] = React.useState<string | null>(null);

    const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      if (e.target.files) {
        const filesArr = Array.from(e.target.files);
        let errorMsg: string | null = null;

        for (const file of filesArr) {
          if (maxSizeMB && file.size > maxSizeMB * 1024 * 1024) {
            errorMsg = `File "${file.name}" exceeds the ${maxSizeMB}MB size limit.`;
            break;
          }
          if (accept && !matchAcceptRule(file.name, file.type, accept)) {
            errorMsg = `File "${file.name}" has an invalid type. Allowed: ${accept}.`;
            break;
          }
        }

        if (errorMsg) {
          setLocalError(errorMsg);
          setSelectedFiles([]);
          onFilesSelected?.([]);
        } else {
          setLocalError(null);
          setSelectedFiles(filesArr);
          onFilesSelected?.(filesArr);
        }
      }
    };

    const handleClear = (e: React.MouseEvent) => {
      e.stopPropagation();
      setSelectedFiles([]);
      setLocalError(null);
      if (inputRef.current) {
        inputRef.current.value = "";
      }
      onFilesSelected?.([]);
    };

    const handleRemoveFile = (idxToRemove: number) => {
      const nextFiles = selectedFiles.filter((_, i) => i !== idxToRemove);
      setSelectedFiles(nextFiles);
      setLocalError(null);
      onFilesSelected?.(nextFiles);
      if (inputRef.current) {
        inputRef.current.value = "";
      }
    };

    const radiusClass = designRadius[radius] || "rounded-xl";

    return (
      <div className={cn("flex flex-col gap-1.5 w-full max-w-md", 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
          onClick={() => !disabled && !isLoading && inputRef.current?.click()}
          className={cn(
            fileInputVariants({ variant, size }),
            radiusClass,
            (isInvalid || !!localError) &&
              "border-rose-500 dark:border-rose-500 focus-within:ring-rose-500/20",
            disabled && "opacity-50 cursor-not-allowed pointer-events-none",
          )}
        >
          <input
            ref={inputRef}
            type="file"
            multiple={multiple}
            accept={accept}
            disabled={disabled}
            onChange={handleFilesChange}
            className="hidden"
            {...props}
          />

          <Icon
            icon="hugeicons:file-attachment"
            className={cn(
              "shrink-0 text-zinc-400 dark:text-zinc-500",
              size === "sm" ? "size-3.5" : size === "lg" ? "size-5" : "size-4",
            )}
          />

          <div
            className={cn(
              "flex-1 text-left truncate font-medium",
              selectedFiles.length > 0
                ? "text-zinc-900 dark:text-zinc-100"
                : "text-zinc-400 dark:text-zinc-500",
            )}
          >
            {selectedFiles.length > 0
              ? showBadges
                ? `${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""} selected`
                : selectedFiles.map((f) => f.name).join(", ")
              : placeholder}
          </div>

          <div className="flex items-center gap-2 shrink-0">
            {isClearable && selectedFiles.length > 0 && (
              <button
                type="button"
                onClick={handleClear}
                className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors cursor-pointer"
              >
                <Icon icon="hugeicons:cancel-01" className="size-3.5" />
              </button>
            )}

            {isLoading ? (
              <Icon
                icon="hugeicons:loading-02"
                className="size-4 animate-spin text-sky-500 shrink-0"
              />
            ) : (
              <span className="px-2 py-1 bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800/80 dark:hover:bg-zinc-700 text-xs font-bold rounded-lg border border-zinc-200 dark:border-zinc-700 transition-colors select-none">
                Browse
              </span>
            )}
          </div>

          {progress !== undefined && progress >= 0 && progress <= 100 && (
            <div className="absolute bottom-0 left-0 right-0 h-[3px] bg-zinc-100 dark:bg-zinc-800">
              <div
                className="h-full bg-sky-500 transition-all duration-300"
                style={{ width: `${progress}%` }}
              />
            </div>
          )}
        </div>

        {showBadges && selectedFiles.length > 0 && (
          <div className="flex flex-row flex-wrap gap-1.5 mt-2">
            {selectedFiles.map((file, idx) => (
              <Badge
                key={`${file.name}-${idx}`}
                variant="flat"
                color="primary"
                isRemovable
                onRemove={() => handleRemoveFile(idx)}
                className="max-w-full truncate"
              >
                {file.name}
              </Badge>
            ))}
          </div>
        )}

        {description && !(isInvalid || !!localError) && (
          <p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-normal">
            {description}
          </p>
        )}

        {(isInvalid || !!localError) && (errorMessage || localError) && (
          <p className="text-[11px] text-rose-500 dark:text-rose-400 leading-normal font-medium">
            {errorMessage || localError}
          </p>
        )}
      </div>
    );
  },
);

FileInput.displayName = "FileInput";

Default

Standard file input with browse trigger, helper description, and attachment icon.

No document selected
Browse

PDF, DOCX, or TXT up to 10MB.

Variants

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

variant: default | bordered | flat | filled | glow | glassmorphism | gradient-border | underlined
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse

Sizes

Supported in small (sm), medium (md), and large (lg) dimensions.

size: sm | md | lg
No file chosen
Browse
No file chosen
Browse
No file chosen
Browse

Required State

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

isRequired: boolean
Upload tax document...
Browse

Simulated Upload Progress & Loading

Display upload loading states and simulated progress indicator bars at the bottom edge.

isLoading: anyprogress: anyundefined: any
Choose file to upload...
Browse

Click browse to select a file, then click the trigger below to start upload simulation.

Validation Feedback

Toggle red indicator borders and feedback messages with isInvalid and errorMessage.

isInvalid: anyerrorMessage: anyformat: anyis: anyallowed: anyfor: anyvalidation: any
No file chosen
Browse

Only PDF format is allowed for validation.

Automatic File Type & Size Validation

Set allowed format extensions by specifying multiple comma-separated formats (using accept like .pdf,.docx,.xlsx) and limit maximum sizes (using maxSizeMB). If selected files violate these rules, error messages are automatically displayed and the input enters invalid state.

accept: stringmaxSizeMB: number
No document selected
Browse

Only PDF, DOCX or XLSX files up to 2.5MB are allowed.

Multiple File Selection

Allows selecting multiple documents from browser file dialog, separating names with commas.

multiple: anyfiles: anyselected: any
No files selected
Browse

Removable Badge Tags

Display selected files as individual removable Badges below the input field, allowing users to remove items individually by clicking the close handle.

showBadges: boolean
Select files to list as badges...
Browse

Props — FileInput

Supported properties for the FileInput component.

PropTypeDefaultDescription
variant'default' | 'bordered' | 'flat' | 'underlined' | 'filled' | 'glassmorphism' | 'gradient-border' | 'glow''default'Visual appearance theme of the input wrapper.
size'sm' | 'md' | 'lg''md'Dimensional layout size scale.
progressnumberPercentage upload progress value (0-100) to render a progress bar.
isLoadingbooleanfalseReplaces browse trigger with a loading/spinning icon.
isClearablebooleantrueEnables a clear button to remove selected files.
showBadgesbooleanfalseRenders selected attachments as individual removable Badges below the input.