For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

File Upload

A file selection and drop zone with validation and simulated progress.

The progress is simulated; connect onUploadComplete to your own file handling. It does not upload files to a server.

Installation

$ pnpm dlx shadcn@latest add @cobalt/file-upload

Source

"use client";

import { Button as ButtonPrimitive } from "@base-ui/react/button";
import {
  RiFileImageLine,
  RiFileExcel2Line,
  RiFileTextLine,
  RiUploadCloudLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

const DEFAULT_EXTENSIONS = ["pdf", "jpg", "jpeg", "png", "xlsx"];
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;

type Phase = "idle" | "uploading" | "complete";
export interface FileUploadProps {
  onUploadComplete?: (file: File) => void;
  allowedExtensions?: readonly string[];
  maxBytes?: number;
  renderFileIcon?: (file: File) => React.ReactNode;
  className?: string;
}

export function formatFileSize(bytes: number) {
  if (bytes >= 1024 * 1024) {
    const mb = bytes / (1024 * 1024);
    return `${mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
  }
  return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}

function ringPath(width: number, height: number) {
  const r = 15,
    x = width - 1,
    y = height - 1;
  return `M ${width / 2} 1 H ${x - r} A ${r} ${r} 0 0 1 ${x} ${1 + r} V ${y - r} A ${r} ${r} 0 0 1 ${x - r} ${y} H ${1 + r} A ${r} ${r} 0 0 1 1 ${y - r} V ${1 + r} A ${r} ${r} 0 0 1 ${1 + r} 1 Z`;
}

const reveal =
  "transition-[opacity,translate,filter] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";

export function FileUpload({
  onUploadComplete,
  allowedExtensions = DEFAULT_EXTENSIONS,
  maxBytes = DEFAULT_MAX_BYTES,
  renderFileIcon,
  className,
}: FileUploadProps) {
  const [phase, setPhase] = React.useState<Phase>("idle");
  const [progress, setProgress] = React.useState(0);
  const [file, setFile] = React.useState<File | null>(null);
  const [rejection, setRejection] = React.useState<string | null>(null);
  const [dragOver, setDragOver] = React.useState(false);
  const [box, setBox] = React.useState({ width: 533, height: 164 });
  const inputRef = React.useRef<HTMLInputElement>(null);
  const boxRef = React.useRef<HTMLButtonElement>(null);
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
  React.useEffect(() => {
    const element = boxRef.current;
    if (!element) {
      return;
    }
    const observer = new ResizeObserver(([entry]) => {
      if (entry.contentRect.width > 0) {
        setBox({
          width: entry.contentRect.width,
          height: entry.contentRect.height,
        });
      }
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  React.useEffect(() => {
    const pending = timers.current;
    return () => {
      for (const timer of pending) {
        clearTimeout(timer);
      }
    };
  }, []);
  function startUpload(next: File) {
    const extension = next.name.split(".").pop()?.toLowerCase() ?? "";
    if (!allowedExtensions.some((value) => value.toLowerCase() === extension)) {
      setRejection(
        `Choose a ${allowedExtensions.map((value) => value.toUpperCase()).join(", ")} file.`
      );
      timers.current.push(setTimeout(() => setRejection(null), 2600));
      return;
    }
    if (next.size > maxBytes) {
      setRejection(`Choose a file under ${formatFileSize(maxBytes)}.`);
      timers.current.push(setTimeout(() => setRejection(null), 2600));
      return;
    }
    setRejection(null);
    setFile(next);
    setProgress(0);
    setPhase("uploading");
    let value = 0;
    const tick = () => {
      value = Math.min(100, value + 2 + Math.random() * 5);
      setProgress(Math.round(value));
      if (value < 100) {
        timers.current.push(setTimeout(tick, 90));
        return;
      }
      setPhase("complete");
      timers.current.push(
        setTimeout(() => {
          onUploadComplete?.(next);
          setPhase("idle");
          setFile(null);
        }, 1600)
      );
    };
    timers.current.push(setTimeout(tick, 250));
  }
  const busy = phase !== "idle";
  const extension = file?.name.split(".").pop()?.toLowerCase();
  const Icon =
    extension === "xlsx"
      ? RiFileExcel2Line
      : ["jpg", "jpeg", "png"].includes(extension ?? "")
        ? RiFileImageLine
        : RiFileTextLine;
  const line = (visible: boolean, delay: string) =>
    cn(
      reveal,
      visible
        ? cn("translate-y-0 opacity-100 blur-none", delay)
        : "translate-y-0 opacity-0 blur-none !duration-200"
    );
  return (
    <div data-slot="file-upload" className={cn("relative w-full", className)}>
      <input
        ref={inputRef}
        type="file"
        aria-label="Choose a file"
        accept={allowedExtensions.map((value) => `.${value}`).join(",")}
        className="sr-only"
        tabIndex={-1}
        onChange={(event) => {
          const next = event.target.files?.[0];
          event.target.value = "";
          if (next) {
            startUpload(next);
          }
        }}
      />
      <ButtonPrimitive
        ref={boxRef}
        type="button"
        aria-label="Choose or drop a file"
        aria-disabled={busy}
        onClick={() => {
          if (!busy) {
            inputRef.current?.click();
          }
        }}
        onDragOver={(event) => {
          event.preventDefault();
          if (!busy) {
            setDragOver(true);
          }
        }}
        onDragLeave={() => setDragOver(false)}
        onDrop={(event) => {
          event.preventDefault();
          setDragOver(false);
          const [next] = event.dataTransfer.files;
          if (next && !busy) {
            startUpload(next);
          }
        }}
        className={cn(
          "group relative h-[164px] w-full shrink-0 rounded-[16px] text-left outline-none transition-colors duration-300 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
          busy
            ? "bg-[var(--surface-raised,var(--background))]"
            : "cursor-pointer bg-[var(--surface-subtle,var(--muted))]"
        )}
      >
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-0 rounded-[16px] border-2 border-dashed border-[var(--border-strong,var(--border))] transition-[opacity,border-color] duration-300 ease-out group-hover:border-[var(--foreground-subtle,var(--ring))] motion-reduce:transition-none",
            dragOver && "border-[var(--foreground-subtle,var(--ring))]",
            busy ? "opacity-0" : "opacity-100"
          )}
        />
        <svg
          aria-hidden
          viewBox={`0 0 ${box.width} ${box.height}`}
          preserveAspectRatio="none"
          className={cn(
            "pointer-events-none absolute inset-0 size-full transition-opacity duration-300 ease-out motion-reduce:transition-none",
            busy ? "opacity-100" : "opacity-0"
          )}
        >
          <rect
            x={1}
            y={1}
            width={box.width - 2}
            height={box.height - 2}
            rx={15}
            fill="none"
            stroke="var(--border)"
            strokeWidth={2}
          />
          <path
            d={ringPath(box.width, box.height)}
            fill="none"
            stroke="var(--file-upload-progress, var(--primary))"
            strokeWidth={2}
            strokeLinecap="round"
            pathLength={100}
            strokeDasharray={`${busy ? (progress >= 100 ? 102 : progress) : 0} 200`}
            className="transition-[stroke-dasharray] duration-200 ease-linear motion-reduce:transition-none"
          />
        </svg>
        <span
          aria-hidden
          className={cn(
            "absolute -top-[9.5px] left-1/2 -translate-x-1/2 rounded-[6px] bg-[var(--file-upload-progress-label,var(--primary))] px-1.5 py-0.5 text-xs leading-4 font-medium tracking-[0.15px] whitespace-nowrap text-primary-foreground tabular-nums [transition:opacity_160ms_ease-out,filter_160ms_ease-out,translate_300ms_ease-out] motion-reduce:transition-none",
            busy
              ? "translate-y-0 opacity-100 blur-none"
              : "-translate-y-2.5 opacity-0 blur-[2px]"
          )}
        >
          {progress}%
        </span>
        <span
          aria-hidden={busy}
          className={cn(
            "absolute inset-0 flex flex-col items-center justify-center gap-3.5",
            busy && "pointer-events-none"
          )}
        >
          <span
            className={cn(
              "flex size-10 items-center justify-center rounded-full bg-[var(--foreground-disabled,var(--muted))] p-2.5",
              line(!busy, "delay-0")
            )}
          >
            <RiUploadCloudLine
              className="size-6 shrink-0 text-[color:var(--file-upload-icon,var(--muted-foreground))] transition-colors duration-150 ease-[ease] group-hover:text-[color:var(--file-upload-icon-hover,var(--foreground))] motion-reduce:transition-none"
              aria-hidden
            />
          </span>
          <span className="flex flex-col items-center gap-2 text-center">
            <span
              className={cn(
                "text-sm leading-5 font-medium text-[color:var(--file-upload-text,var(--muted-foreground))]",
                rejection && "text-destructive",
                line(!busy, "delay-40")
              )}
            >
              {rejection ?? (
                <>
                  Drop a file here or{" "}
                  <span className="text-[color:var(--file-upload-link,var(--primary))]">
                    browse
                  </span>
                </>
              )}
            </span>
            <span
              className={cn(
                "text-[13px] leading-[18px] text-[color:var(--file-upload-text,var(--muted-foreground))]",
                line(!busy, "delay-[80ms]")
              )}
            >
              {allowedExtensions
                .filter((value) => value.toLowerCase() !== "jpeg")
                .map((value) => value.toUpperCase())
                .join(", ")}{" "}
              (max {formatFileSize(maxBytes)})
            </span>
          </span>
        </span>
        <span
          aria-hidden={!busy}
          className={cn(
            "absolute inset-0 flex flex-col items-center justify-center",
            !busy && "pointer-events-none"
          )}
        >
          <span
            className={cn(
              "flex size-10 items-center justify-center rounded-full border border-border bg-[var(--surface-raised,var(--background))] p-2",
              line(busy, "delay-0")
            )}
          >
            {file &&
              (renderFileIcon ? (
                renderFileIcon(file)
              ) : (
                <Icon
                  className="size-6 shrink-0 text-[color:var(--file-upload-text,var(--muted-foreground))]"
                  aria-hidden
                />
              ))}
          </span>
          <span
            className={cn(
              "mt-3.5 max-w-[90%] truncate text-sm leading-5 font-medium text-foreground",
              line(busy, "delay-40")
            )}
          >
            {file?.name}
          </span>
          <span
            className={cn(
              "mt-1 h-[18px] text-[13px] leading-[18px] text-[color:var(--file-upload-text,var(--muted-foreground))]",
              line(busy, "delay-[80ms]")
            )}
          >
            {phase === "complete"
              ? "File ready"
              : `Preparing ${file ? formatFileSize(file.size) : ""}…`}
          </span>
        </span>
      </ButtonPrimitive>
      <span role="status" className="sr-only">
        {rejection ??
          (busy
            ? `${phase === "complete" ? "File ready" : "Preparing file"}, ${progress}%`
            : "")}
      </span>
    </div>
  );
}