---
title: File Upload
description: Upload layouts for files, queues, and image sets.
group: Blocks
order: 24
---

Practical upload states for single files, active queues, and image collections.

## Dropzone

A familiar dashed dropzone with a browse action.

```tsx
"use client";

import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type FileUpload01Props = {
  className?: string;
};

type SelectedFile = {
  id: string;
  file: File;
};

function 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`;
}

function UploadIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M12 16V6" />
      <path d="m8 10 4-4 4 4" />
      <path d="M20 16.5V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1.5" />
    </svg>
  );
}

function FileIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M14 3v4a1 1 0 0 0 1 1h4" />
      <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
    </svg>
  );
}

export function FileUpload01({ className }: FileUpload01Props) {
  const inputId = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = useState(false);
  const [files, setFiles] = useState<SelectedFile[]>([]);

  function addFiles(list: FileList | File[]) {
    const next = Array.from(list).map((file) => ({
      id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()}`,
      file,
    }));
    setFiles((current) => [...current, ...next]);
  }

  return (
    <section className={cn("@container w-full bg-background", className)}>
      <div className="mx-auto w-full max-w-xl px-4 py-10 @[32rem]:px-6 @[32rem]:py-14">
        <div className="text-center">
          <h2 className="text-xl font-medium tracking-wide text-foreground @[32rem]:text-2xl">
            Upload files
          </h2>
          <p className="mt-2 text-sm tracking-wide text-muted-foreground">
            Drop files here or browse from your device.
          </p>
        </div>

        <label
          htmlFor={inputId}
          onDragEnter={(event) => {
            event.preventDefault();
            setDragging(true);
          }}
          onDragOver={(event) => {
            event.preventDefault();
            setDragging(true);
          }}
          onDragLeave={(event) => {
            event.preventDefault();
            setDragging(false);
          }}
          onDrop={(event) => {
            event.preventDefault();
            setDragging(false);
            if (event.dataTransfer.files.length) {
              addFiles(event.dataTransfer.files);
            }
          }}
          className={cn(
            "mt-8 flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border px-6 py-12 transition-colors",
            dragging && "border-foreground bg-muted",
          )}
        >
          <span className="flex size-10 items-center justify-center text-muted-foreground [&_svg]:size-5">
            <UploadIcon />
          </span>
          <div className="text-center">
            <p className="text-sm font-medium tracking-wide text-foreground">
              Drag and drop files
            </p>
            <p className="mt-1 text-sm tracking-wide text-muted-foreground">
              PNG, JPG, PDF up to 10MB
            </p>
          </div>
          <Button
            type="button"
            size="sm"
            variant="secondary"
            onClick={(event) => {
              event.preventDefault();
              inputRef.current?.click();
            }}
          >
            Browse files
          </Button>
          <input
            ref={inputRef}
            id={inputId}
            type="file"
            multiple
            className="sr-only"
            onChange={(event) => {
              if (event.target.files?.length) {
                addFiles(event.target.files);
                event.target.value = "";
              }
            }}
          />
        </label>

        {files.length > 0 ? (
          <ul className="mt-4 flex flex-col gap-2">
            {files.map((item) => (
              <li
                key={item.id}
                className="flex items-center gap-3 rounded-xl bg-muted px-3 py-2.5"
              >
                <span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
                  <FileIcon />
                </span>
                <div className="min-w-0 flex-1">
                  <p className="truncate text-sm font-medium tracking-wide text-foreground">
                    {item.file.name}
                  </p>
                  <p className="text-xs tracking-wide text-muted-foreground tabular-nums">
                    {formatSize(item.file.size)}
                  </p>
                </div>
                <button
                  type="button"
                  className="shrink-0 text-xs tracking-wide text-muted-foreground"
                  onClick={() =>
                    setFiles((current) =>
                      current.filter((file) => file.id !== item.id),
                    )
                  }
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        ) : null}
      </div>
    </section>
  );
}
```

### Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add file-upload-01
```

### Usage

```tsx
import { FileUpload01 } from "@/components/blocks/file-upload/file-upload-01"
```

```tsx
<FileUpload01 />
```

## Upload queue

A compact drop strip followed by upload progress for each attached file.

```tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Progress, ProgressValue } from "@/components/ui/progress";
import { cn } from "@/lib/utils";

type FileUpload02Props = {
  className?: string;
};

type QueueFile = {
  id: string;
  name: string;
  size: number;
  progress: number;
};

function 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`;
}

function FileIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M14 3v4a1 1 0 0 0 1 1h4" />
      <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
    </svg>
  );
}

function UploadIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M12 16V6" />
      <path d="m8 10 4-4 4 4" />
      <path d="M20 16.5V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1.5" />
    </svg>
  );
}

export function FileUpload02({ className }: FileUpload02Props) {
  const inputId = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = useState(false);
  const [files, setFiles] = useState<QueueFile[]>([]);

  function addFiles(list: FileList | File[]) {
    const next = Array.from(list).map((file) => ({
      id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()}`,
      name: file.name,
      size: file.size,
      progress: 8 + Math.round(Math.random() * 12),
    }));
    setFiles((current) => [...current, ...next]);
  }

  const hasPending = files.some((file) => file.progress < 100);

  useEffect(() => {
    if (!hasPending) return;

    const timer = window.setInterval(() => {
      setFiles((current) =>
        current.map((file) => {
          if (file.progress >= 100) return file;
          const step = 4 + Math.round(Math.random() * 10);
          return {
            ...file,
            progress: Math.min(100, file.progress + step),
          };
        }),
      );
    }, 280);

    return () => window.clearInterval(timer);
  }, [hasPending]);

  return (
    <section className={cn("@container w-full bg-background", className)}>
      <div className="mx-auto w-full max-w-xl px-4 py-10 @[32rem]:px-6 @[32rem]:py-14">
        <div className="flex flex-wrap items-end justify-between gap-3">
          <div>
            <h2 className="text-xl font-medium tracking-wide text-foreground @[32rem]:text-2xl">
              Attachments
            </h2>
            <p className="mt-2 text-sm tracking-wide text-muted-foreground">
              Upload docs and media. Progress updates as files process.
            </p>
          </div>
          <Button
            type="button"
            size="sm"
            onClick={() => inputRef.current?.click()}
          >
            Add files
          </Button>
          <input
            ref={inputRef}
            id={inputId}
            type="file"
            multiple
            className="sr-only"
            onChange={(event) => {
              if (event.target.files?.length) {
                addFiles(event.target.files);
                event.target.value = "";
              }
            }}
          />
        </div>

        <label
          htmlFor={inputId}
          onDragEnter={(event) => {
            event.preventDefault();
            setDragging(true);
          }}
          onDragOver={(event) => {
            event.preventDefault();
            setDragging(true);
          }}
          onDragLeave={(event) => {
            event.preventDefault();
            setDragging(false);
          }}
          onDrop={(event) => {
            event.preventDefault();
            setDragging(false);
            if (event.dataTransfer.files.length) {
              addFiles(event.dataTransfer.files);
            }
          }}
          className={cn(
            "mt-6 flex cursor-pointer items-center gap-3 rounded-xl border border-dashed border-border px-4 py-4 transition-colors",
            dragging && "border-foreground bg-muted",
          )}
        >
          <span className="flex size-9 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
            <UploadIcon />
          </span>
          <div className="min-w-0">
            <p className="text-sm font-medium tracking-wide text-foreground">
              Drop files to upload
            </p>
            <p className="text-sm tracking-wide text-muted-foreground">
              Or click Add files — max 25MB each
            </p>
          </div>
        </label>

        {files.length > 0 ? (
          <ul className="mt-4 flex flex-col gap-3">
            {files.map((file) => (
              <li key={file.id} className="rounded-xl bg-muted px-4 py-3">
                <div className="flex items-start gap-3">
                  <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
                    <FileIcon />
                  </span>
                  <div className="min-w-0 flex-1">
                    <div className="flex items-baseline justify-between gap-3">
                      <p className="truncate text-sm font-medium tracking-wide text-foreground">
                        {file.name}
                      </p>
                      <div className="flex shrink-0 items-center gap-2">
                        <p className="text-xs tracking-wide text-muted-foreground tabular-nums">
                          {formatSize(file.size)}
                        </p>
                        <button
                          type="button"
                          className="text-xs tracking-wide text-muted-foreground"
                          onClick={() =>
                            setFiles((current) =>
                              current.filter((item) => item.id !== file.id),
                            )
                          }
                        >
                          Remove
                        </button>
                      </div>
                    </div>
                    {file.progress >= 100 ? (
                      <p className="mt-2 text-xs tracking-wide text-muted-foreground">
                        Uploaded
                      </p>
                    ) : (
                      <Progress value={file.progress} className="mt-2">
                        <ProgressValue className="text-xs" />
                      </Progress>
                    )}
                  </div>
                </div>
              </li>
            ))}
          </ul>
        ) : null}
      </div>
    </section>
  );
}
```

### Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add file-upload-02
```

### Usage

```tsx
import { FileUpload02 } from "@/components/blocks/file-upload/file-upload-02"
```

```tsx
<FileUpload02 />
```

## Image gallery

A cover image with open slots for the rest of a project set.

```tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type FileUpload03Props = {
  className?: string;
};

type Slot = {
  id: string;
  label: string;
  src: string | null;
};

const initialSlots: Slot[] = [
  { id: "cover", label: "Cover", src: null },
  { id: "shot-1", label: "Shot 1", src: null },
  { id: "shot-2", label: "Shot 2", src: null },
  { id: "shot-3", label: "Shot 3", src: null },
];

function ImageIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <rect x="3" y="3" width="18" height="18" rx="2" />
      <circle cx="9" cy="9" r="1.5" />
      <path d="m21 15-3.5-3.5a2 2 0 0 0-2.8 0L6 20" />
    </svg>
  );
}

function PlusIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M12 5v14" />
      <path d="M5 12h14" />
    </svg>
  );
}

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  );
}

export function FileUpload03({ className }: FileUpload03Props) {
  const inputId = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const activeSlotRef = useRef<string | null>(null);
  const slotsRef = useRef<Slot[]>(initialSlots);
  const [slots, setSlots] = useState<Slot[]>(initialSlots);
  slotsRef.current = slots;

  useEffect(() => {
    return () => {
      for (const slot of slotsRef.current) {
        if (slot.src) URL.revokeObjectURL(slot.src);
      }
    };
  }, []);

  function openPicker(slotId?: string) {
    activeSlotRef.current = slotId ?? null;
    inputRef.current?.click();
  }

  function assignImages(list: FileList) {
    const images = Array.from(list).filter((file) =>
      file.type.startsWith("image/"),
    );
    if (!images.length) return;

    setSlots((current) => {
      const next = current.map((slot) => ({ ...slot }));
      const urls = images.map((file) => URL.createObjectURL(file));

      if (activeSlotRef.current) {
        const index = next.findIndex(
          (slot) => slot.id === activeSlotRef.current,
        );
        if (index >= 0) {
          if (next[index].src) URL.revokeObjectURL(next[index].src);
          next[index] = { ...next[index], src: urls[0] };
          for (const url of urls.slice(1)) URL.revokeObjectURL(url);
          return next;
        }
      }

      let urlIndex = 0;
      for (let i = 0; i < next.length && urlIndex < urls.length; i += 1) {
        if (!next[i].src) {
          next[i] = { ...next[i], src: urls[urlIndex] };
          urlIndex += 1;
        }
      }
      for (const url of urls.slice(urlIndex)) URL.revokeObjectURL(url);
      return next;
    });

    activeSlotRef.current = null;
  }

  function clearSlot(slotId: string) {
    setSlots((current) =>
      current.map((slot) => {
        if (slot.id !== slotId) return slot;
        if (slot.src) URL.revokeObjectURL(slot.src);
        return { ...slot, src: null };
      }),
    );
  }

  return (
    <section className={cn("@container w-full bg-background", className)}>
      <div className="mx-auto w-full max-w-2xl px-4 py-10 @[32rem]:px-6 @[32rem]:py-14">
        <div className="flex flex-wrap items-end justify-between gap-3">
          <div>
            <h2 className="text-xl font-medium tracking-wide text-foreground @[32rem]:text-2xl">
              Project gallery
            </h2>
            <p className="mt-2 text-sm tracking-wide text-muted-foreground">
              Add a cover and supporting shots. JPG or PNG, up to 5MB.
            </p>
          </div>
          <Button
            type="button"
            size="sm"
            variant="secondary"
            onClick={() => openPicker()}
          >
            Upload images
          </Button>
          <input
            ref={inputRef}
            id={inputId}
            type="file"
            accept="image/*"
            multiple
            className="sr-only"
            onChange={(event) => {
              if (event.target.files?.length) {
                assignImages(event.target.files);
                event.target.value = "";
              }
            }}
          />
        </div>

        <div className="mt-6 grid grid-cols-2 gap-3 @[40rem]:grid-cols-4">
          {slots.map((slot) =>
            slot.src ? (
              <div
                key={slot.id}
                className="relative aspect-square overflow-hidden rounded-xl bg-muted"
              >
                <img
                  src={slot.src}
                  alt={slot.label}
                  className="size-full object-cover"
                />
                <button
                  type="button"
                  aria-label={`Remove ${slot.label}`}
                  onClick={() => clearSlot(slot.id)}
                  className="absolute top-2 right-2 flex size-6 items-center justify-center rounded-full bg-background text-foreground [&_svg]:size-3.5"
                >
                  <CloseIcon />
                </button>
              </div>
            ) : (
              <button
                key={slot.id}
                type="button"
                onClick={() => openPicker(slot.id)}
                className="flex aspect-square flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border text-muted-foreground"
              >
                <span className="[&_svg]:size-5">
                  <PlusIcon />
                </span>
                <span className="text-xs tracking-wide">{slot.label}</span>
              </button>
            ),
          )}
        </div>

        <p className="mt-4 flex items-center gap-2 text-xs tracking-wide text-muted-foreground">
          <span className="[&_svg]:size-3.5">
            <ImageIcon />
          </span>
          Cover is shown first in the public gallery.
        </p>
      </div>
    </section>
  );
}
```

### Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add file-upload-03
```

### Usage

```tsx
import { FileUpload03 } from "@/components/blocks/file-upload/file-upload-03"
```

```tsx
<FileUpload03 />
```
