Documentation
File Uploader

File Uploader

Component to upload images, files or folders.

Dependencies

This component is based on DropZone and FileTrigger from React Aria Components

Usage

import { FileUploader } from "@inspectra/ui/file-uploader"
 
export default function DefaultDemo() {
  // We need to store the files in a state
  const [files, setFiles] = React.useState<File[] | null>(null)
  return <FileUploader files={files} setFiles={setFiles} />
}

This component is internally divided into 2 parts:

  • Dropzone: this is the main container that allows you to drag and drop files or select them from the file explorer. To set your props, it is necessary to send them through dropzoneProps.
  • FileTrigger: This is the component that allows you to select files from the file explorer. To set your props, you need to send them through triggerProps.

Accepting specific file types

Using the dropzoneProps prop called acceptedFileTypes we can specify which mime file types are allowed.

The value must be an object with a common MIME type (opens in a new tab) as keys and an array of file extensions as values (similar to showOpenFilePicker's (opens in a new tab) types accept option).

For example, let's make FileUploader only accept pdf files:

import { FileUploader } from "@inspectra/ui/file-uploader"
 
export default function DefaultDemo() {
  const [files, setFiles] = React.useState<File[] | null>(null)
  return (
    <FileUploader
      files={files}
      setFiles={setFiles}
      triggerProps={{
        acceptedFileTypes: ["application/pdf"],
      }}
    />
  )
}

Variants

FileUploader has two variants: default and avatar. With the avatar variant we can display a component with circular format.

For correct use of this variant, you must set the triggerProps.allowsMultiple prop to false.

As you can see in the example code, we make use of the styles prop to modify the trigger so that it does not have padding like the default variant. In turn, in case there is a file we modify the label with the image so that it occupies the whole container of the component.

Custom label

By default, the component label says Upload a file. We can modify it by indicating a different label through the label prop.

You can also modify the icon through the icon prop

Custom design and logic

If you need to modify the styles of the component and want to create your own style and logic, you can do it easily through the styles prop.

If you want to have full control of the drag and drop, you can do it through all the functions supported by the dropzoneProps object.

If you want to control what happens when a file is selected in the trigger, you can do it through onSelect inside the triggerProps` prop.

🚫

If you use the image previews through the URL.createObjectURL API you should be aware to use a useEffect that returns a cleanup function that iterates over each file and executes a URL.revokeObjectURL. This is done to avoid memory leaks, since the URL.createObjectURL function uses memory approximately 10 times the size of the image.

How to add styles to drag and drop events

If you want to add styles to the drag and drop events, you can do it through the following css selectors:

  • data-hovered: Whether the dropzone is currently hovered with a mouse.
  • data-focused: Whether the dropzone is focused, either via a mouse or keyboard.
  • data-focus-visible: Whether the dropzone is keyboard focused.
  • data-drop-target: Whether the dropzone is the drop target.

The way to use it with the component would be like this:

import { FileUploader } from "@inspectra/ui/file-uploader"
 
export default function DefaultDemo() {
  // We need to store the files in a state
  const [files, setFiles] = React.useState<File[] | null>(null)
  return (
    <FileUploader
      files={files}
      setFiles={setFiles}
      styles={{
        dropzone: cn(
          "data-[hovered=true]:bg-red-500",
          "data-[focused=true]:bg-blue-500",
          "data-[focus-visible=true]:bg-green-500"
          //...
        ),
      }}
    />
  )
}

With image editor

The following is an example of how to use an image editor such as Pintura (opens in a new tab) together with FileUploader in NextJS

Request an API key

To use Pintura in production you need to request an API key from Pintura (opens in a new tab). If you want to use it in development mode, you can use without an API key.

Install Pintura

npm install @pqina/pintura @pqina/react-pintura
yarn add @pqina/pintura @pqina/react-pintura
pnpm add @pqina/pintura @pqina/react-pintura

Transpile package

We have to instruct NextJS to transpile the Pintura library, we can do so like this

// next.config.js
module.exports = {
  transpilePackages: ["@pqina/pintura", "@pqina/react-pintura"],
}

Dialog styles (optional)

If you want the Editor to look like a Neon Dialog, add the following CSS in a custom file:

.PinturaModal {
  @apply !bg-black-overlay-a10 !blur-none;
}
.PinturaRoot {
  @apply !m-5 !rounded-md !bg-background !p-3 !font-sans !text-gray-12 !shadow-lg dark:!bg-gray-2 sm:!rounded-lg;
}
.PinturaShapeStyleLabel {
  @apply !text-gray-12;
}
.PinturaInputDimension input {
  @apply placeholder:!text-gray-12;
}
.PinturaNavGroup .PinturaButtonExport {
  @apply !bg-gray-12 !text-gray-1;
}
.PinturaNav {
  @apply !p-3;
}
.pintura-editor {
  --editor-max-width: 50em;
  --editor-max-height: 40em;
}

Function to open the editor

We are going to use openDefaultEditor in order to have a controlled component, since we are not looking to use the pre-defined Dialog components provided by the library. If you want a different configuration, you can review the Pintura docs and choose the implementation you like best

// This function is called when the user taps the edit button.
// It opens the editor and returns the modified file when done
const editImage = (
  image: FileUploaderType,
  done: (output: FileUploaderType) => void
) => {
  const imageFile = image.pintura ? image.pintura.file : image
  const imageState = image.pintura ? image.pintura.data : {}
 
  const editor = openDefaultEditor({
    src: imageFile,
    imageState,
  })
 
  editor.on("process", ({ dest, imageState }) => {
    Object.assign(dest, {
      pintura: { file: imageFile, data: imageState },
    })
    done(dest)
  })
}

To learn more about the different events of the Editor visit the Pintura documents where the different events (opens in a new tab)

Then, create a function that uses editImage as callback to be able to modify the image

const editImageFile = useCallback(
  (index: number, file: FileUploaderType) => {
    editImage(file, (output) => {
      const updatedFiles = [...files]
 
      // replace original image with new image
      updatedFiles[index] = output
 
      // revoke preview URL for old image
      if (file.preview) {
        URL.revokeObjectURL(file.preview)
      }
 
      // set new preview URL
      Object.assign(output, {
        preview: URL.createObjectURL(output),
      })
 
      // update view
      setFiles(updatedFiles)
    })
  },
  [files, setFiles]
)

All together

Finally, we render the component with the functions we created:

import * as React from "react"
import { openDefaultEditor, type PinturaImageState } from "@pqina/pintura"
 
import "@pqina/pintura/pintura.css"
 
import { FileUploader } from "@inspectra/ui/file-uploader"
import { formatFileSize } from "@inspectra/ui/file-uploader/utils"
 
export interface FileUploaderType extends File {
  preview?: string
  pintura?: {
    file: File
    data: PinturaImageState
  }
}
 
const editImage = (
  image: FileUploaderType,
  done: (output: FileUploaderType) => void
) => {
  const imageFile = image.pintura ? image.pintura.file : image
  const imageState = image.pintura ? image.pintura.data : {}
 
  const editor = openDefaultEditor({
    src: imageFile,
    imageState,
  })
 
  editor.on("process", ({ dest, imageState }) => {
    Object.assign(dest, {
      pintura: { file: imageFile, data: imageState },
    })
    done(dest)
  })
}
 
export const DemoWithEditor = () => {
  const [files, setFiles] = React.useState<FileUploaderType[] | null>(null)
 
  const fileUploaderRef = React.useRef<HTMLInputElement | null>(null)
 
  const editImageFile = React.useCallback(
    (index: number, file: FileUploaderType) => {
      editImage(file, (output) => {
        const updatedFiles = [...files]
 
        // replace original image with new image
        updatedFiles[index] = output
 
        // revoke preview URL for old image
        if (file.preview) {
          URL.revokeObjectURL(file.preview)
        }
 
        // set new preview URL
        Object.assign(output, {
          preview: URL.createObjectURL(output),
        })
 
        // update view
        setFiles(updatedFiles)
      })
    },
    [files]
  )
 
  const removeFile = React.useCallback(
    (file: FileUploaderType) => {
      const newFiles = files!.filter((f) => f !== file)
      setFiles(newFiles)
    },
    [files, setFiles]
  )
 
  React.useEffect(() => {
    return () => {
      // Revoke the Object URL to avoid memory leaks
      files?.forEach((file) => URL.revokeObjectURL(file.preview!))
    }
  }, [files])
 
  return (
    <div className="space-y-2 rounded-xl border p-4">
      {files && files?.length > 0 && (
        <div className="flex flex-col gap-4">
          {files.map((file, index) => (
            <div
              key={file.name}
              className="flex items-center gap-4 rounded-md bg-gray-100 p-4"
            >
              <img
                src={URL.createObjectURL(file)}
                className="size-16 rounded-full object-cover"
                alt={file.name}
              />
              <div className="flex flex-1 flex-col gap-2">
                <span className="font-bold">{file.name}</span>
                <span className="text-sm text-gray-500">
                  {formatFileSize(file.size)}
                </span>
              </div>
              <div className="space-x-4">
                <button
                  onClick={() => editImageFile(index, file)}
                  aria-label="Edit file"
                >
                  Edit
                </button>
                <button
                  onClick={() => removeFile(file)}
                  aria-label="Remove file"
                >
                  Remove
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
      <section className="relative h-[200px]">
        <FileUploader
          ref={fileUploaderRef}
          files={files}
          setFiles={setFiles}
          styles={{
            dropzone: "size-full rounded-md",
            trigger: "size-full",
          }}
        />
      </section>
    </div>
  )
}

Demo

Upload a file

Building an image editor

In some situations, we may only need a component that allows us to display an image and allow us to edit it. For this, we will show an example of how to do it through the Pintura editor, following the same installation steps mentioned here

import * as React from "react"
import { openDefaultEditor, type PinturaImageState } from "@pqina/pintura"
 
import "@pqina/pintura/pintura.css"
 
import { Button } from "@inspectra/ui/button"
import { Edit } from "lucide-react"
 
export interface ImageEditorType extends File {
  preview?: string
  pintura?: {
    file: File
    data: PinturaImageState
  }
}
 
const editImage = (
  image: ImageEditorType,
  done: (output: ImageEditorType) => void
) => {
  const imageFile = image.pintura ? image.pintura.file : image
  const imageState = image.pintura ? image.pintura.data : {}
 
  const editor = openDefaultEditor({
    src: imageFile,
    imageState,
  })
 
  editor.on("process", ({ dest, imageState }) => {
    Object.assign(dest, {
      pintura: { file: imageFile, data: imageState },
    })
    done(dest)
  })
}
 
const defaultImage =
  "https://images.pexels.com/photos/5913510/pexels-photo-5913510.jpeg"
 
const fetchDefaultImageAsBlob = async () => {
  const response = await fetch(defaultImage)
  const blob = await response.blob()
  return new File([blob], "defaultImage.jpeg", { type: "image/jpeg" })
}
 
export const OnlyWithImageEditor = () => {
  const [editorResult, setEditorResult] = React.useState<File | null>(null)
 
  const editImageFile = React.useCallback((file: ImageEditorType) => {
    editImage(file, (output) => {
      // revoke preview URL for old image
      if (file.preview) {
        URL.revokeObjectURL(file.preview)
      }
 
      // set new preview URL
      Object.assign(output, {
        preview: URL.createObjectURL(output),
      })
 
      // update view
      setEditorResult(output)
    })
  }, [])
 
  React.useEffect(() => {
    fetchDefaultImageAsBlob().then((defaultFile) => {
      setEditorResult(defaultFile)
    })
  }, [])
 
  if (!editorResult) {
    return null
  }
 
  return (
    <div className="relative h-96 w-full object-cover">
      <Button
        variant="secondary"
        size="sm"
        className="absolute bottom-4 right-4"
        onClick={() => editImageFile(editorResult)}
      >
        <Edit className="mr-2 size-3" />
        Edit image
      </Button>
      <img
        alt="File"
        className="size-full rounded-lg object-cover"
        src={URL.createObjectURL(editorResult)}
      />
    </div>
  )
}

Demo

API Reference

PropTypeDefault
files
File[] | null
-
setFiles
(files: File[] | null) => void
-
label
string | React.ReactNode
Upload a file
icon
React.ReactNode | false
false
styles
{ dropzone?: string; trigger?: string }
-
variant
"default" | "avatar"
default
dropzoneProps
Omit<DropZoneProps, 'className' | 'children'>
-
triggerProps
Omit<FileTriggerProps, 'children'>
-