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
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
Prop | Type | Default |
---|---|---|
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'> | - |