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
npx @arctis-sh/@arctis-sh/ui@latest add file-upload-01Usage
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.
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-02Usage
import { FileUpload02 } from "@/components/blocks/file-upload/file-upload-02"<FileUpload02 />Image gallery
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-03Usage
import { FileUpload03 } from "@/components/blocks/file-upload/file-upload-03"<FileUpload03 />