Dropzone

Dropzone lets users upload files into a UI

Usage

Use a dropzone when the user needs to upload an image or file.

Instalation

Run the following command to install the react-dropzone package:

npm i react-dropzone

Copy and paste the following code into your project.

'use client'

import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import {
  useDropzone,
  type Accept,
  type DropzoneProps as PrimitiveDropzoneProps,
} from 'react-dropzone'

import { createSafeContext } from '@/lib/createSafeContext'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'

export const MIME_TYPES = {
  // Images
  png: 'image/png',
  gif: 'image/gif',
  jpeg: 'image/jpeg',
  svg: 'image/svg+xml',
  webp: 'image/webp',
  avif: 'image/avif',

  // Documents
  mp4: 'video/mp4',
  zip: 'application/zip',
  csv: 'text/csv',
  pdf: 'application/pdf',
  doc: 'application/msword',
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  xls: 'application/vnd.ms-excel',
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  ppt: 'application/vnd.ms-powerpoint',
  pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  exe: 'application/vnd.microsoft.portable-executable',
}

export const IMAGE_MIME_TYPE = [
  MIME_TYPES.png,
  MIME_TYPES.gif,
  MIME_TYPES.jpeg,
  MIME_TYPES.svg,
  MIME_TYPES.webp,
  MIME_TYPES.avif,
]

export const PDF_MIME_TYPE = [MIME_TYPES.pdf]
export const MS_WORD_MIME_TYPE = [MIME_TYPES.doc, MIME_TYPES.docx]
export const MS_EXCEL_MIME_TYPE = [MIME_TYPES.xls, MIME_TYPES.xlsx]
export const MS_POWERPOINT_MIME_TYPE = [MIME_TYPES.ppt, MIME_TYPES.pptx]
export const EXE_MIME_TYPE = [MIME_TYPES.exe]

type DropzoneContextValue = {
  loading: boolean
  idle: boolean
  accepted: boolean
  rejected: boolean
}

const [DropZoneProvider, useDropzoneState] = createSafeContext<
  DropzoneContextValue & { id: string }
>({ name: 'DropzoneContext' })

export interface DropzoneProps
  extends Omit<PrimitiveDropzoneProps, 'children' | 'accept'>,
    VariantProps<typeof dropzoneVariants> {
  className?: string
  loading?: boolean
  name?: string
  children?: React.ReactNode | ((state: DropzoneContextValue) => JSX.Element)
  accept?: Accept | string[]

  /** Get open function as ref. Useful for open the files dialog from an external button */
  openRef?: React.ForwardedRef<() => void | undefined>
}

const dropzoneVariants = cva(
  'group relative cursor-pointer w-full text-center overflow-hidden rounded-sm p-4 outline-none transition-all active:scale-[0.98] peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
  {
    variants: {
      variant: {
        filled:
          'bg-surfaceContainer data-[accepted=true]:bg-success/12 data-[rejected=true]:bg-error/12',
        outlined:
          'outline outline-offset-0 outline-outlineVariant data-[accepted=true]:outline-2 data-[rejected=true]:outline-2 data-[accepted=true]:outline-primary data-[rejected=true]:outline-error',
      },
    },
    defaultVariants: {
      variant: 'filled',
    },
  }
)

export const defaultProps: Partial<DropzoneProps> = {
  loading: false,
  multiple: false,
  maxSize: Infinity,
  autoFocus: false,
  noClick: false, // disable or enable click to open files
  noDrag: false,
  noDragEventsBubbling: false,
  noKeyboard: false,
  useFsAccessApi: true,
}

const DropzoneLoading = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>((props, ref) => {
  const { loading } = useDropzoneState()
  if (!loading) return null
  return <div ref={ref} {...props} />
})
DropzoneLoading.displayName = 'DropzoneLoading'

const DropzoneIdle = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { idle } = useDropzoneState()
  if (!idle) return null
  return (
    <div
      ref={ref}
      className={cn('animate-in zoom-in-50', className)}
      {...props}
    />
  )
})
DropzoneIdle.displayName = 'DropzoneIdle'

const DropzoneAccept = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { accepted } = useDropzoneState()
  if (!accepted) return null
  return (
    <div
      ref={ref}
      className={cn('text-onSuccessContainer animate-in zoom-in-50', className)}
      {...props}
    />
  )
})
DropzoneAccept.displayName = 'DropzoneAccept'

const DropzoneReject = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { rejected } = useDropzoneState()
  if (!rejected) return null
  return (
    <div
      ref={ref}
      className={cn('text-onErrorContainer animate-in zoom-in-50', className)}
      {...props}
    />
  )
})
DropzoneReject.displayName = 'DropzoneReject'

const DropzoneContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { accepted, rejected } = useDropzoneState()

  return (
    <div
      ref={ref}
      className={cn(
        'relative z-10 text-onSurface',
        accepted && 'text-onSuccessContainer',
        rejected && 'text-onErrorContainer',
        className
      )}
      {...props}
    />
  )
})
DropzoneContent.displayName = 'DropzoneContent'

const DropzonePreview = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  return (
    <div
      ref={ref}
      className={cn(
        'relative mr-4 grid aspect-square h-16 shrink-0 place-items-center overflow-hidden rounded-xs border border-outline/12 bg-surface text-onSurfaceVariant',
        className
      )}
      {...props}
    />
  )
})
DropzonePreview.displayName = 'DropzonePreview'

const DropzoneLabel = React.forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
  const { accepted, rejected, id } = useDropzoneState()

  return (
    <Label
      ref={ref}
      className={cn(
        'mb-3 text-label-lg text-onSurfaceVariant',
        accepted && 'text-primary',
        rejected && 'text-error',
        className
      )}
      htmlFor={id}
      {...props}
    />
  )
})
DropzoneLabel.displayName = 'DropzoneLabel'

const DropzoneRoot = React.forwardRef<HTMLInputElement, DropzoneProps>(
  (
    {
      accept,
      children,
      className,
      disabled,
      loading = false,
      name,
      variant,
      openRef,
      ...props
    },
    ref
  ) => {
    const randomId = React.useId()
    const id = name ?? randomId
    const isDisabled = disabled || loading

    const { getRootProps, getInputProps, isDragAccept, isDragReject, open } =
      useDropzone({
        disabled: isDisabled,
        accept: Array.isArray(accept)
          ? accept.reduce((r, key) => ({ ...r, [key]: [] }), {})
          : accept,
        ...defaultProps,
        ...props,
      })

    if (
      typeof openRef === 'object' &&
      openRef !== null &&
      'current' in openRef
    ) {
      openRef.current = open
    }

    const isIdle = !isDragAccept && !isDragReject
    const dropzoneState = {
      loading,
      accepted: isDragAccept,
      rejected: isDragReject,
      idle: isIdle,
    }

    return (
      <DropZoneProvider value={{ id, ...dropzoneState }}>
        <input
          ref={ref}
          {...getInputProps()}
          id={id}
          name={name}
          className="peer"
          disabled={isDisabled}
        />
        <div
          {...getRootProps()}
          data-accepted={dropzoneState.accepted}
          data-rejected={dropzoneState.rejected}
          data-idle={dropzoneState.idle}
          data-loading={dropzoneState.loading}
          className={cn(dropzoneVariants({ variant, className }))}
        >
          {typeof children === 'function' ? children(dropzoneState) : children}
          {isIdle && !isDisabled && (
            <span
              className={cn(
                'absolute inset-0 z-0 bg-onSurface opacity-0 transition-opacity group-hover:opacity-4 group-active:opacity-8'
              )}
            />
          )}
        </div>
      </DropZoneProvider>
    )
  }
)
DropzoneRoot.displayName = 'Dropzone'

const DropZone = Object.assign(DropzoneRoot, {
  Idle: DropzoneIdle,
  Accept: DropzoneAccept,
  Reject: DropzoneReject,
  Content: DropzoneContent,
  Preview: DropzonePreview,
  Label: DropzoneLabel,
})

export {
  DropZone,
  DropzoneRoot,
  DropzoneIdle,
  DropzoneAccept,
  DropzoneReject,
  DropzoneContent,
  DropzonePreview,
  DropzoneLabel,
}

Update the import paths to match your project setup.

Examples

Single file

import { useState } from 'react'

import { DropZone, IMAGE_MIME_TYPE } from '@/components/ui/drop-zone'
import { Icon } from '@/components/ui/icon'

export const SingleFileDropzone = () => {
  const [image, setImage] = useState<File | null>(null)

  return (
    <DropZone
      accept={IMAGE_MIME_TYPE}
      onDropAccepted={(files) => {
        setImage(files[0])
      }}
    >
      <DropZone.Content>
        <DropZone.Accept>
          <Icon symbol="thumb_up" className="text-6xl opacity-60" />
        </DropZone.Accept>
        <DropZone.Reject>
          <Icon symbol="thumb_down" className="text-6xl opacity-60" />
        </DropZone.Reject>
        <DropZone.Idle>
          {image ? (
            <img
              src={URL.createObjectURL(image)}
              alt="Preview"
              className="animate-in zoom-in-0 mx-auto mb-4 aspect-square h-[60px] rounded-sm object-cover object-center"
            />
          ) : (
            <Icon symbol="image" className="text-6xl opacity-60" />
          )}
        </DropZone.Idle>
        <div className="space-y-2">
          <p>Click to upload an Image. Or drag and drop</p>
          <p className="text-body-sm opacity-60">
            Allowed formats: .png, .jpeg, .webp
          </p>
        </div>
      </DropZone.Content>
    </DropZone>
  )
}

Multiple files

import { useState } from 'react'

import { parseFileSize } from '@/lib/utils'
import {
  DropZone,
  IMAGE_MIME_TYPE,
  PDF_MIME_TYPE,
} from '@/components/ui/drop-zone'
import { Icon } from '@/components/ui/icon'
import { IconButton } from '@/components/ui/icon-button'

export const MultipleFilesDropzone = () => {
  const [files, setFiles] = useState<File[]>([])

  return (
      <DropZone
        multiple
        accept={[...IMAGE_MIME_TYPE, ...PDF_MIME_TYPE]}
        onDropAccepted={(files) => {
          setFiles(files)
        }}
      >
        <DropZone.Content>
          <DropZone.Accept>
            <Icon symbol="thumb_up" className="text-6xl opacity-60" />
          </DropZone.Accept>
          <DropZone.Reject>
            <Icon symbol="thumb_down" className="text-6xl opacity-60" />
          </DropZone.Reject>
          <DropZone.Idle>
            <Icon symbol="upload_file" className="text-6xl opacity-60" />
          </DropZone.Idle>
          <div className="space-y-2">
            <p>Click to upload files. Or drag and drop</p>
            <p className="text-body-sm opacity-60">
              Allowed formats: .png, .jpeg, .webp, .pdf
            </p>
          </div>
        </DropZone.Content>
      </DropZone>
      {files.length > 0 && (
        <div className="w-full space-y-2 p-4">
          {files.map((file) => (
            <div key={file.name} className="flex items-center text-start">
              <DropZone.Preview>
                {(() => {
                  if (PDF_MIME_TYPE.includes(file.type)) {
                    return (
                      <Icon
                        symbol="description"
                        className="text-3xl opacity-60"
                      />
                    )
                  }
                  if (IMAGE_MIME_TYPE.includes(file.type)) {
                    return (
                      <img
                        src={URL.createObjectURL(file)}
                        alt="Preview"
                        className="animate-in zoom-in-0 aspect-square h-full object-cover object-center"
                      />
                    )
                  }
                  return (
                    <Icon symbol="folder" className="text-3xl opacity-60" />
                  )
                })()}
              </DropZone.Preview>
              <div>
                <p className="text-title-md">{file.name}</p>
                <p className="text-body-sm text-onSurfaceVariant">
                  {parseFileSize({ size: file.size, metric: 'MB' })} MB
                </p>
              </div>
              <IconButton
                variant="standard"
                className="ml-auto opacity-50 hover:opacity-100"
                onClick={() => setFiles(files.filter((f) => f !== file))}
              >
                <Icon symbol="close" />
              </IconButton>
            </div>
          ))}
        </div>
      )}
  )
}

Outlined dropzone

import { useState } from 'react'

import { DropZone, IMAGE_MIME_TYPE } from '@/components/ui/drop-zone'
import { Icon } from '@/components/ui/icon'

export const OutlinedDropzone = () => {
  const [image, setImage] = useState<File | null>(null)

  return (
    <DropZone
      variant="outlined"
      accept={IMAGE_MIME_TYPE}
      onDropAccepted={(files) => {
        setImage(files[0])
      }}
    >
      <DropZone.Content>
        <DropZone.Accept>
          <Icon symbol="thumb_up" className="text-6xl opacity-60" />
        </DropZone.Accept>
        <DropZone.Reject>
          <Icon symbol="thumb_down" className="text-6xl opacity-60" />
        </DropZone.Reject>
        <DropZone.Idle>
          {image ? (
            <img
              src={URL.createObjectURL(image)}
              alt="Preview"
              className="animate-in zoom-in-0 mx-auto mb-4 aspect-square h-[60px] rounded-sm object-cover object-center"
            />
          ) : (
            <Icon symbol="image" className="text-6xl opacity-60" />
          )}
        </DropZone.Idle>
        <div className="space-y-2">
          <p>Click to upload an Image. Or drag and drop</p>
          <p className="text-body-sm opacity-60">
            Allowed formats: .png, .jpeg, .webp
          </p>
        </div>
      </DropZone.Content>
    </DropZone>
  )
}