All components

File Drop

Upload zone that lifts and glows while a file is over it.

1.4 KB gzippedZero dependenciesReduced-motion safeBase UI & Radix compatible

Drag files here, or click to browse

image/*

Installation

npx shadcn@latest add https://innsite.com.mx/r/file-drop.json

Source

"use client";

import { useRef, useState } from "react";

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

interface FileDropProps {
  onFiles?: (files: File[]) => void;
  /** Passed straight to the underlying input */
  accept?: string;
  multiple?: boolean;
  className?: string;
}

const formatSize = (bytes: number) => {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};

/**
 * Drag-and-drop upload zone that lifts and glows while a file is over it.
 * Wraps a real file input, so clicking and keyboard both work.
 */
export function FileDrop({
  onFiles,
  accept,
  multiple = true,
  className,
}: FileDropProps) {
  const [over, setOver] = useState(false);
  const [files, setFiles] = useState<File[]>([]);
  const inputRef = useRef<HTMLInputElement>(null);

  const accept_ = (list: FileList | null) => {
    if (!list?.length) return;
    const next = Array.from(list);
    setFiles(next);
    onFiles?.(next);
  };

  return (
    <div className={cn("w-full", className)} data-slot="file-drop">
      <div
        onDragOver={(event) => {
          event.preventDefault();
          setOver(true);
        }}
        onDragLeave={() => setOver(false)}
        onDrop={(event) => {
          event.preventDefault();
          setOver(false);
          accept_(event.dataTransfer.files);
        }}
        onClick={() => inputRef.current?.click()}
        className={cn(
          "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed p-10 text-center transition-all duration-200 motion-reduce:transition-none",
          over
            ? "-translate-y-0.5 border-brand bg-brand/5 shadow-lg shadow-brand/10"
            : "border-border hover:border-muted-foreground/50 hover:bg-muted/40"
        )}
      >
        <svg
          width="28"
          height="28"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden
          className={cn(
            "transition-colors",
            over ? "text-brand" : "text-muted-foreground"
          )}
        >
          <path d="M12 15V3M8 7l4-4 4 4" />
          <path d="M3 15v4a2 2 0 002 2h14a2 2 0 002-2v-4" />
        </svg>
        <p className="text-sm font-medium">
          {over ? "Drop to upload" : "Drag files here, or click to browse"}
        </p>
        <p className="text-xs text-muted-foreground">
          {accept ? accept.replace(/,/g, ", ") : "Any file type"}
        </p>

        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          onChange={(event) => accept_(event.target.files)}
          className="sr-only"
        />
      </div>

      {files.length > 0 && (
        <ul className="mt-3 flex flex-col gap-1.5">
          {files.map((file) => (
            <li
              key={`${file.name}-${file.size}`}
              className="flex items-center justify-between gap-3 rounded-lg border bg-card px-3 py-2 text-sm"
            >
              <span className="truncate">{file.name}</span>
              <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
                {formatSize(file.size)}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}