shadimage

File Upload

A drag-and-drop file upload surface with accepted file type icons and a compact file list.

components/extend/file-upload.tsx

"use client"

import * as React from "react"
import { BorderBeam } from "border-beam"

import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
import { FileThumbnail } from "@/components/extend/file-thumbnail"
import { IconPlaceholder } from "@/components/icon-placeholder"

function FileImageGlyph(props: InlineRegistryIconProps) {
  return (
    <IconPlaceholder
      lucide="FileImage"
      tabler="IconPhoto"
      hugeicons="FileImageIcon"
      phosphor="FileImageIcon"
      remixicon="RiFileImageLine"
      {...props}
    />
  )
}
function FileSpreadsheetGlyph(props: InlineRegistryIconProps) {
  return (
    <IconPlaceholder
      lucide="FileSpreadsheet"
      tabler="IconFileSpreadsheet"
      hugeicons="FileSpreadsheetIcon"
      phosphor="FileXlsIcon"
      remixicon="RiFileExcel2Line"
      {...props}
    />
  )
}
function FileUploadGlyph(props: InlineRegistryIconProps) {
  return (
    <IconPlaceholder
      lucide="FileUp"
      tabler="IconFileUpload"
      hugeicons="FileUploadIcon"
      phosphor="FileArrowUpIcon"
      remixicon="RiFileUploadLine"
      {...props}
    />
  )
}
type FileUploadItem = {
  id: string
  name: string
  type: string
  size: number
  url: string
}
type AcceptedFileType = {
  label: string
  icon: React.ComponentType<InlineRegistryIconProps>
}
type FileUploadProps = {
  accept?: string
  acceptedFileTypes?: AcceptedFileType[]
  borderBeamTheme?: React.ComponentProps<typeof BorderBeam>["theme"]
  browseLabel?: string
  className?: string
  description?: string
  draggingLabel?: string
  multiple?: boolean
  showBorderBeam?: boolean
  showFileList?: boolean
  title?: string
  onFilesAccepted?: (files: File[]) => void
  onFilesChange?: (files: FileUploadItem[]) => void
}
const ACCEPTED_FILE_TYPES: AcceptedFileType[] = [
  { label: "Image", icon: FileImageGlyph },
  { label: "PDF", icon: FileUploadGlyph },
  { label: "Sheet", icon: FileSpreadsheetGlyph },
]
const DEFAULT_ACCEPT = [
  ".pdf",
  ".doc",
  ".docx",
  ".xlsx",
  ".csv",
  ".png",
  ".jpg",
  ".jpeg",
  "application/pdf",
  "application/msword",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  "text/csv",
  "image/png",
  "image/jpeg",
].join(",")
const ICON_TRANSFORMS = [
  {
    idle: "translate(-78%, -50%) rotate(-8deg)",
    active: "translate(-114%, -50%) rotate(-12deg) scale(1.08)",
  },
  {
    idle: "translate(-50%, -50%) rotate(0deg)",
    active: "translate(-50%, -50%) rotate(0deg) scale(1.18)",
  },
  {
    idle: "translate(-22%, -50%) rotate(8deg)",
    active: "translate(14%, -50%) rotate(12deg) scale(1.08)",
  },
]
function formatBytes(bytes: number) {
  if (bytes === 0) return "0 B"
  const units = ["B", "KB", "MB", "GB"]
  const index = Math.min(
    Math.floor(Math.log(bytes) / Math.log(1024)),
    units.length - 1
  )
  return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
function matchesAccept(file: File, accept?: string) {
  if (!accept) return true
  return accept.split(",").some((rawToken) => {
    const token = rawToken.trim().toLowerCase()
    if (!token) return false
    if (token.startsWith(".")) return file.name.toLowerCase().endsWith(token)
    if (token.endsWith("/*")) {
      return file.type.toLowerCase().startsWith(token.slice(0, -1))
    }
    return file.type.toLowerCase() === token
  })
}
function toUploadItems(files: FileList | File[]): FileUploadItem[] {
  return Array.from(files).map((file) => ({
    id: `${file.name}-${file.size}-${file.lastModified}`,
    name: file.name,
    type: file.type || "Unknown type",
    size: file.size,
    url: URL.createObjectURL(file),
  }))
}
function UploadIconCluster({
  acceptedFileTypes,
  isDragging,
}: {
  acceptedFileTypes: AcceptedFileType[]
  isDragging: boolean
}) {
  const singleIcon = acceptedFileTypes.length === 1
  return (
    <div className="relative h-14 w-36">
      {acceptedFileTypes.map((item, index) => (
        <Card
          key={item.label}
          className={cn(
            "absolute top-1/2 left-1/2 grid size-12 place-items-center rounded-xl bg-background text-muted-foreground transition-[transform,color,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] before:rounded-[calc(var(--radius-xl)-1px)]",
            "motion-reduce:transition-none",
            index === 1 && "z-10",
            isDragging &&
              "bg-popover text-foreground shadow-md shadow-black/10 not-dark:bg-clip-border dark:shadow-black/25"
          )}
          style={{
            transform: singleIcon
              ? `translate(-50%, -50%) scale(${isDragging ? 1.14 : 1})`
              : isDragging
                ? ICON_TRANSFORMS[index]?.active
                : ICON_TRANSFORMS[index]?.idle,
          }}
        >
          <item.icon className="size-5" />
        </Card>
      ))}
    </div>
  )
}
export function FileUpload({
  accept = DEFAULT_ACCEPT,
  acceptedFileTypes = ACCEPTED_FILE_TYPES,
  borderBeamTheme = "light",
  browseLabel = "Browse files",
  className,
  description = "PDF, DOC/DOCX, XLSX, CSV, PNG, or JPG",
  draggingLabel = "Drop to add",
  multiple = true,
  showBorderBeam = true,
  showFileList = true,
  title = "Click to upload or drop files",
  onFilesAccepted,
  onFilesChange,
}: FileUploadProps) {
  const dragDepthRef = React.useRef(0)
  const inputRef = React.useRef<HTMLInputElement>(null)
  const [isDragging, setIsDragging] = React.useState(false)
  const [files, setFiles] = React.useState<FileUploadItem[]>([])
  const [rejectionMessage, setRejectionMessage] = React.useState<string | null>(
    null
  )
  const commitFiles = React.useCallback(
    (nextFiles: FileList | File[]) => {
      const acceptedFiles = Array.from(nextFiles)
        .filter((file) => matchesAccept(file, accept))
        .slice(0, multiple ? undefined : 1)
      if (acceptedFiles.length === 0) {
        setRejectionMessage("This file type is not supported here.")
        return
      }
      setRejectionMessage(null)
      onFilesAccepted?.(acceptedFiles)
      const items = toUploadItems(acceptedFiles)
      setFiles((previousFiles) => {
        previousFiles.forEach((file) => URL.revokeObjectURL(file.url))
        return items
      })
      onFilesChange?.(items)
    },
    [accept, multiple, onFilesAccepted, onFilesChange]
  )
  React.useEffect(() => {
    return () => {
      files.forEach((file) => URL.revokeObjectURL(file.url))
    }
  }, [files])
  const openFileDialog = React.useCallback(() => {
    inputRef.current?.click()
  }, [])
  const dropzone = (
    <div
      role="button"
      tabIndex={0}
      className={cn(
        "relative flex min-h-64 cursor-pointer flex-col items-center justify-center gap-5 overflow-hidden rounded-[1.125rem] border border-dashed bg-background px-6 py-10 text-center transition-[border-color,background-color] duration-200 ease-out",
        "motion-reduce:transition-none",
        isDragging
          ? "border-foreground/40 bg-accent/35"
          : "border-foreground/20 hover:border-foreground/35 hover:bg-muted/35 dark:border-foreground/25 dark:hover:border-foreground/40"
      )}
      onClick={openFileDialog}
      onDragEnter={(event) => {
        event.preventDefault()
        dragDepthRef.current += 1
        setIsDragging(true)
      }}
      onDragLeave={(event) => {
        event.preventDefault()
        dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
        if (dragDepthRef.current === 0) setIsDragging(false)
      }}
      onDragOver={(event) => event.preventDefault()}
      onDrop={(event) => {
        event.preventDefault()
        dragDepthRef.current = 0
        setIsDragging(false)
        if (event.dataTransfer.files.length > 0) {
          commitFiles(event.dataTransfer.files)
        }
      }}
      onKeyDown={(event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault()
          openFileDialog()
        }
      }}
    >
      <UploadIconCluster
        acceptedFileTypes={acceptedFileTypes}
        isDragging={isDragging}
      />
      <div className="space-y-1">
        <div className="text-sm font-medium">{title}</div>
        <div className="text-xs text-muted-foreground">{description}</div>
        {rejectionMessage ? (
          <div className="text-xs text-destructive">{rejectionMessage}</div>
        ) : null}
      </div>
      <div className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-xs text-muted-foreground">
        <IconPlaceholder
          lucide="Upload"
          tabler="IconUpload"
          hugeicons="Upload01Icon"
          phosphor="UploadSimpleIcon"
          remixicon="RiUpload2Line"
          className="size-3.5"
        />
        <span>{isDragging ? draggingLabel : browseLabel}</span>
      </div>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        className="hidden"
        onChange={(event) => {
          if (event.target.files) {
            commitFiles(event.target.files)
            event.currentTarget.value = ""
          }
        }}
      />
    </div>
  )
  return (
    <div className={cn("space-y-3", className)}>
      {showBorderBeam ? (
        <BorderBeam
          active={isDragging}
          borderRadius={18}
          brightness={2.4}
          className="rounded-[1.125rem]"
          colorVariant="ocean"
          duration={2.4}
          size="md"
          strength={1}
          theme={borderBeamTheme}
        >
          {dropzone}
        </BorderBeam>
      ) : (
        dropzone
      )}
      {showFileList && files.length > 0 ? (
        <div className="rounded-xl border bg-background">
          {files.map((file) => (
            <div
              key={file.id}
              className="flex items-center gap-3 border-b px-3 py-2.5 last:border-b-0"
            >
              <FileThumbnail
                file={{
                  name: file.name,
                  type: file.type,
                }}
                previewImageUrl={
                  file.type.startsWith("image/") ? file.url : null
                }
                className="size-10 shrink-0 rounded-lg"
              />
              <div className="min-w-0 flex-1">
                <div className="truncate text-sm font-medium">{file.name}</div>
                <div className="truncate text-xs text-muted-foreground">
                  {file.type} - {formatBytes(file.size)}
                </div>
              </div>
              <div className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
                Ready
              </div>
            </div>
          ))}
        </div>
      ) : null}
    </div>
  )
}
type InlineRegistryIconProps = Omit<
  React.ComponentProps<"svg">,
  "children" | "strokeWidth"
> & { strokeWidth?: number }
Interaction
drag

DependenciesBundle weight · light

border-beam@^1.0.1