# Image upload with sorting.

> Image upload with sorting.

A shadcn/ui block from the [@reui](https://reui.io) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/reui/c-file-upload-8).

- Page, with a live preview: https://shadcn.exchange/r/reui/c-file-upload-8
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-file-upload-8-light-1b9f6c4586e454ef.png
- Registry item: https://reui.io/r/new-york-v4/c-file-upload-8.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://reui.io/r/new-york-v4/c-file-upload-8.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `cn`, `sonner`
- Registry items: `@reui/alert`, `button`, `card`, `progress`, `@reui/sortable`

## Files

### `c-file-upload-8.tsx`

```tsx
"use client"

import { useCallback, useEffect, useState } from "react"
import {
  Alert,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert"
import {
  Sortable,
  SortableItem,
  SortableItemHandle,
} from "@/components/reui/sortable"
import { cn } from "cn"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"

interface ImageFile {
  id: string
  file: File
  preview: string
  progress: number
  status: "uploading" | "completed" | "error"
  error?: string
}

type SortableImage = {
  id: string
  src: string
  alt: string
  type: "default" | "uploaded"
}

interface ImageUploadProps {
  maxFiles?: number
  maxSize?: number
  accept?: string
  className?: string
  onImagesChange?: (images: ImageFile[]) => void
  onUploadComplete?: (images: ImageFile[]) => void
}

export function Pattern({
  maxFiles = 5, // Changed to 5 as per UI reference
  maxSize = 10 * 1024 * 1024, // 10MB as per UI reference
  accept = "image/*",
  className,
  onImagesChange,
  onUploadComplete,
}: ImageUploadProps) {
  const [images, setImages] = useState<ImageFile[]>([])
  const [isDragging, setIsDragging] = useState(false)
  const [errors, setErrors] = useState<string[]>([])
  const [allImages, setAllImages] = useState<SortableImage[]>([
    {
      id: "default-1",
      src: "https://picsum.photos/1000/800?grayscale&random=6",
      alt: "Product view 1",
      type: "default",
    },
    {
      id: "default-2",
      src: "https://picsum.photos/1000/800?grayscale&random=7",
      alt: "Product view 2",
      type: "default",
    },
    {
      id: "default-3",
      src: "https://picsum.photos/1000/800?grayscale&random=8",
      alt: "Product view 3",
      type: "default",
    },
    {
      id: "default-4",
      src: "https://picsum.photos/1000/800?grayscale&random=9",
      alt: "Product view 4",
      type: "default",
    },
    {
      id: "default-5",
      src: "https://picsum.photos/1000/800?grayscale&random=10",
      alt: "Product view 5",
      type: "default",
    },
  ])

  // Helper function to create SortableImage from ImageFile
  const createSortableImage = useCallback(
    (imageFile: ImageFile): SortableImage => ({
      id: imageFile.id,
      src: imageFile.preview,
      alt: imageFile.file.name,
      type: "uploaded",
    }),
    []
  )

  // Ensure arrays never contain undefined items
  useEffect(() => {
    setAllImages((prev) => prev.filter((item) => item && item.id))
    setImages((prev) => prev.filter((item) => item && item.id))
  }, [])

  const validateFile = (file: File): string | null => {
    if (!file.type.startsWith("image/")) {
      return "File must be an image"
    }
    if (file.size > maxSize) {
      return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(1)}MB`
    }
    if (images.length >= maxFiles) {
      return `Maximum ${maxFiles} files allowed`
    }
    return null
  }

  const addImages = useCallback(
    (files: FileList | File[]) => {
      const newImages: ImageFile[] = []
      const newErrors: string[] = []

      Array.from(files).forEach((file) => {
        const error = validateFile(file)
        if (error) {
          newErrors.push(`${file.name}: ${error}`)
          return
        }

        const imageFile: ImageFile = {
          id: `${Date.now()}-${Math.random()}`,
          file,
          preview: URL.createObjectURL(file),
          progress: 0,
          status: "uploading",
        }

        newImages.push(imageFile)
      })

      if (newErrors.length > 0) {
        setErrors((prev) => [...prev, ...newErrors])
      }

      if (newImages.length > 0) {
        const updatedImages = [...images, ...newImages]
        setImages(updatedImages)
        onImagesChange?.(updatedImages)

        // Add new images to allImages for sorting
        const newSortableImages = newImages.map(createSortableImage)
        setAllImages((prev) => [...prev, ...newSortableImages])

        // Simulate upload progress
        newImages.forEach((imageFile) => {
          simulateUpload(imageFile)
        })
      }
    },
    [images, maxSize, maxFiles, onImagesChange, createSortableImage]
  )

  const simulateUpload = (imageFile: ImageFile) => {
    let progress = 0
    const interval = setInterval(() => {
      progress += Math.random() * 20
      if (progress >= 100) {
        progress = 100
        clearInterval(interval)

        setImages((prev) =>
          prev.map((img) =>
            img.id === imageFile.id
              ? { ...img, progress: 100, status: "completed" as const }
              : img
          )
        )

        // Check if all uploads are complete
        const updatedImages = images.map((img) =>
          img.id === imageFile.id
            ? { ...img, progress: 100, status: "completed" as const }
            : img
        )

        if (updatedImages.every((img) => img.status === "completed")) {
          onUploadComplete?.(updatedImages)
        }
      } else {
        setImages((prev) =>
          prev.map((img) =>
            img.id === imageFile.id ? { ...img, progress } : img
          )
        )
      }
    }, 100)
  }

  const removeImage = useCallback(
    (id: string) => {
      // Remove from allImages
      setAllImages((prev) => prev.filter((img) => img.id !== id))

      // If it's an uploaded image, also remove from images array and revoke URL
      const uploadedImage = images.find((img) => img.id === id)
      if (uploadedImage) {
        URL.revokeObjectURL(uploadedImage.preview)
        setImages((prev) => prev.filter((img) => img.id !== id))
      }
    },
    [images]
  )

  const handleDragEnter = useCallback((e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(true)
  }, [])

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(false)
  }, [])

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
  }, [])

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault()
      e.stopPropagation()
      setIsDragging(false)

      const files = e.dataTransfer.files
      if (files.length > 0) {
        addImages(files)
      }
    },
    [addImages]
  )

  const openFileDialog = useCallback(() => {
    const input = document.createElement("input")
    input.type = "file"
    input.multiple = true
    input.accept = accept
    input.onchange = (e) => {
      const target = e.target as HTMLInputElement
      if (target.files) {
        addImages(target.files)
      }
    }
    input.click()
  }, [accept, addImages])

  const formatBytes = (bytes: number): string => {
    if (bytes === 0) return "0 Bytes"
    const k = 1024
    const sizes = ["Bytes", "KB", "MB", "GB"]
    const i = Math.floor(Math.log(bytes) / Math.log(k))
    return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
  }

  return (
    <div className={cn("w-full max-w-4xl", className)}>
      {/* Instructions */}
      <div className="mb-4 text-center">
        <p className="text-muted-foreground text-sm">
          Upload up to {maxFiles} images (JPG, PNG, GIF, WebP, max{" "}
          {formatBytes(maxSize)} each). <br />
          Drag and drop images to reorder.
          {images.length > 0 && ` ${images.length}/${maxFiles} uploaded.`}
        </p>
      </div>

      {/* Image Grid with Sortable */}
      <div className="mb-6">
        {/* Combined Images Sortable */}
        <Sortable
          value={allImages.map((item) => item.id)}
          onValueChange={(newItemIds) => {
            // Reconstruct the allImages array based on the new order
            const newAllImages = newItemIds
              .map((itemId) => {
                // First try to find in allImages (default images)
                const existingImage = allImages.find((img) => img.id === itemId)
                if (existingImage) return existingImage

                // If not found, it's a newly uploaded image
                const uploadedImage = images.find((img) => img.id === itemId)
                if (uploadedImage) {
                  return createSortableImage(uploadedImage)
                }
                return null
              })
              .filter((item): item is SortableImage => item !== null)

            setAllImages(newAllImages)

            toast.success("Images reordered successfully!", {
              duration: 3000,
            })
          }}
          getItemValue={(item) => item}
          strategy="grid"
          className="grid auto-rows-fr grid-cols-5 gap-2.5"
        >
          {allImages.map((item) => (
            <SortableItem key={item.id} value={item.id}>
              <div className="bg-accent/50 group/item border-border hover:bg-accent/70 relative flex shrink-0 items-center justify-center rounded-md border shadow-none transition-all duration-200 hover:z-10 data-[dragging=true]:z-50">
                <img
                  src={item.src}
                  className="pointer-events-none h-[120px] w-full rounded-md object-cover"
                  alt={item.alt}
                />

                {/* Drag Handle */}
                <SortableItemHandle className="absolute start-2 top-2 cursor-grab opacity-0 group-hover/item:opacity-100 active:cursor-grabbing">
                  <Button
                    variant="outline"
                    size="icon"
                    className="size-6 rounded-full dark:bg-zinc-800 hover:dark:bg-zinc-700"
                  >
                    <IconPlaceholder
                      lucide="GripVerticalIcon"
                      tabler="IconGripVertical"
                      hugeicons="DragDropVerticalIcon"
                      phosphor="DotsSixVerticalIcon"
                      remixicon="RiDraggable"
                      className="size-3.5"
                    />
                  </Button>
                </SortableItemHandle>

                {/* Remove Button Overlay */}
                <Button
                  onClick={() => removeImage(item.id)}
                  variant="outline"
                  size="icon"
                  className="absolute end-2 top-2 size-6 rounded-full opacity-0 shadow-sm group-hover/item:opacity-100 dark:bg-zinc-800 hover:dark:bg-zinc-700"
                >
                  <IconPlaceholder
                    lucide="XIcon"
                    tabler="IconX"
                    hugeicons="MultiplicationSignIcon"
                    phosphor="XIcon"
                    remixicon="RiCloseLine"
                    className="size-3.5"
                  />
                </Button>
              </div>
            </SortableItem>
          ))}
        </Sortable>
      </div>

      {/* Upload Area */}
      <Card
        className={cn(
          "rounded-md border-dashed shadow-none transition-colors",
          isDragging
            ? "border-primary bg-primary/5"
            : "border-muted-foreground/25 hover:border-muted-foreground/50"
        )}
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
      >
        <CardContent className="text-center">
          <div className="border-border mx-auto mb-3 flex size-[32px] items-center justify-center rounded-full border">
            <IconPlaceholder
              lucide="CloudUploadIcon"
              tabler="IconCloudUpload"
              hugeicons="CloudUploadIcon"
              phosphor="CloudArrowUpIcon"
              remixicon="RiUploadCloud2Line"
              className="size-4"
            />
          </div>
          <h3 className="text-foreground mb-0.5 text-sm font-medium">
            Choose a file or drag & drop here.
          </h3>
          <span className="text-secondary-foreground mb-3 block text-xs font-normal">
            JPEG, PNG, up to {formatBytes(maxSize)}.
          </span>
          <Button size="sm" onClick={openFileDialog}>
            Browse File
          </Button>
        </CardContent>
      </Card>

      {/* Upload Progress Cards */}
      {images.length > 0 && (
        <div className="mt-6 space-y-3">
          {images.map((imageFile) => (
            <Card key={imageFile.id} className="rounded-md shadow-none">
              <CardContent className="flex items-center gap-2 p-2.5">
                <div className="border-border flex size-[32px] shrink-0 items-center justify-center rounded-md border">
                  <IconPlaceholder
                    lucide="ImageIcon"
                    tabler="IconPhoto"
                    hugeicons="ImageIcon"
                    phosphor="ImageIcon"
                    remixicon="RiImageLine"
                    className="text-muted-foreground size-4"
                  />
                </div>
                <div className="flex w-full flex-col gap-1.5">
                  <div className="-mt-2 flex w-full items-center justify-between gap-2.5">
                    <div className="flex items-center gap-2.5">
                      <span className="text-foreground text-xs leading-none font-medium">
                        {imageFile.file.name}
                      </span>
                      <span className="text-muted-foreground text-xs leading-none font-normal">
                        {formatBytes(imageFile.file.size)}
                      </span>
                      {imageFile.status === "uploading" && (
                        <p className="text-muted-foreground text-xs">
                          Uploading... {Math.round(imageFile.progress)}%
                        </p>
                      )}
                    </div>
                    <Button
                      onClick={() => removeImage(imageFile.id)}
                      variant="ghost"
                      size="icon"
                      className="size-6"
                    >
                      <IconPlaceholder
                        lucide="CircleXIcon"
                        tabler="IconCircleX"
                        hugeicons="CancelCircleIcon"
                        phosphor="XCircleIcon"
                        remixicon="RiCloseCircleLine"
                        className="size-3.5"
                      />
                    </Button>
                  </div>

                  <Progress
                    value={imageFile.progress}
                    className={cn(
                      "h-1 transition-all duration-300",
                      "[&>div]:bg-zinc-950 dark:[&>div]:bg-zinc-50"
                    )}
                  />
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}

      {/* Error Messages */}
      {errors.length > 0 && (
        <Alert variant="destructive" className="mt-5">
          <IconPlaceholder
            lucide="CircleAlertIcon"
            tabler="IconAlertCircle"
            hugeicons="AlertCircleIcon"
            phosphor="WarningCircleIcon"
            remixicon="RiErrorWarningLine"
          />
          <AlertTitle>File upload error(s)</AlertTitle>
          <AlertDescription>
            {errors.map((error, index) => (
              <p key={index} className="last:mb-0">
                {error}
              </p>
            ))}
          </AlertDescription>
        </Alert>
      )}
    </div>
  )
}
```
