File Upload

Upload layouts for files, queues, and image sets.

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

Dropzone

A familiar dashed dropzone with a browse action.

Upload files

Drop files here or browse from your device.

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

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

Usage

import { FileUpload01 } from "@/components/blocks/file-upload/file-upload-01"
<FileUpload01 />

Upload queue

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

Attachments

Upload docs and media. Progress updates as files process.

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

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

Usage

import { FileUpload02 } from "@/components/blocks/file-upload/file-upload-02"
<FileUpload02 />

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

Project gallery

Add a cover and supporting shots. JPG or PNG, up to 5MB.

Cover is shown first in the public gallery.

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

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

Usage

import { FileUpload03 } from "@/components/blocks/file-upload/file-upload-03"
<FileUpload03 />