Upload

free

Upload handles file selection, drop zones, validation, and removable file lists.

Preview
Dark

Drag and Drop file here or Choose file

Supported formats: XLS, XLSXMaximum size: 25MB
XLS

Table Name.xls

3 MB

import { useState } from 'react';
import CloseButton from '@/components/ui/CloseButton';
import Upload from '@/components/ui/Upload';
import Alert from '@/components/ui/Alert';
import { PiUploadSimple } from 'react-icons/pi'

const ACCEPTED_FILES =
  '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';

const MAX_SIZE = 25 * 1000 * 1000;

const createDemoFile = (name: string, size: number, type: string) =>
  ({ name, size, type }) as File;

const INITIAL_FILES = [
  createDemoFile('Table Name.xls', 3000000, 'application/vnd.ms-excel'),
];

const formatSize = (size: number) =>
  size >= 1000 * 1000
    ? `${Math.round(size / (1000 * 1000))} MB`
    : `${Math.round(size / 1000)} KB`;

export default function UsageDemo() {
  const [files, setFiles] = useState<File[]>(INITIAL_FILES);
  const [message, setMessage] = useState('');

  const beforeUpload = (files: FileList | null) => {
    const allowed = [
      'application/vnd.ms-excel',
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    ];

    if (!files) {
      return true;
    }

    for (const file of files) {
      if (!allowed.includes(file.type)) {
        return 'Upload an XLS or XLSX spreadsheet.';
      }

      if (file.size > MAX_SIZE) {
        return 'Spreadsheet must be smaller than 25MB.';
      }
    }

    return true;
  };

  return (
    <div className="grid w-full max-w-xl gap-4">
      <Upload
        draggable
        accept={ACCEPTED_FILES}
        uploadLimit={1}
        fileList={files}
        beforeUpload={beforeUpload}
        onBeforeUpload={(result) => setMessage(String(result))}
        onChange={(_, fileList) => {
          setFiles(fileList.slice(-1));
          setMessage('');
        }}
        className="w-full"
        onFileRemove={(_, fileList) => setFiles(fileList)}
        tip={
          <div className="mt-4 flex flex-wrap items-center justify-between gap-4 text-xs text-muted-foreground">
            <span>Supported formats: XLS, XLSX</span>
            <span>Maximum size: 25MB</span>
          </div>
        }
        customFileItem={({ file, onRemove, FileIcon }) => (
          <div className="rounded-card border p-4">
            <div className="flex items-start justify-between gap-4">
              <div className="flex min-w-0 items-center gap-4">
                <FileIcon file={file} />
                <div className="grid min-w-0">
                  <p className="truncate text-sm font-semibold text-foreground">
                    {file.name}
                  </p>
                  <p className="text-xs text-muted-foreground">
                    {formatSize(file.size)}
                  </p>
                </div>
              </div>
              <CloseButton
                aria-label={`Remove ${file.name}`}
                onClick={onRemove}
              />
            </div>
          </div>
        )}
      >
        <div className="grid justify-items-center gap-4 px-8 py-12 text-center">
          <span className="relative flex size-12 items-center justify-center">
            <span className="h-10 w-10 border rounded-lg flex items-center justify-center">
              <PiUploadSimple className="text-lg" />
            </span>
          </span>
          <div className="grid gap-2">
            <p className="font-medium">
              Drag and Drop file here or{' '}
              <span className="underline underline-offset-2">
                Choose file
              </span>
            </p>
          </div>
        </div>
      </Upload>
      {message ? (
        <Alert 
          showIcon
          closable
          variant="destructive"
          onClose={() => setMessage('')}
        >
          {message}
        </Alert>
      ) : null}
    </div>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add Upload

Examples

Basic

Preview
Dark
import Upload from '@/components/ui/Upload';

export default function BasicDemo() {
  return (
    <div className="w-full max-w-xl flex justify-center min-w-0">
      <Upload className="mx-auto" />
    </div>
  );
}

Drag And Drop

Preview
Dark

Drop files here or browse

Multiple documents can be attached at once.

import Upload from '@/components/ui/Upload';
import { PiCloudArrowUp } from 'react-icons/pi'

export default function DragAndDropDemo() {
  return (
    <Upload draggable multiple className="w-full max-w-xl">
      <div className="grid justify-items-center gap-4 py-8 px-8 text-center">
        <span className="flex size-12 items-center justify-center rounded-control border bg-muted text-muted-foreground">
          <PiCloudArrowUp className="size-6" />
        </span>
        <div className="grid gap-2">
          <p className="font-medium text-foreground">
            Drop files here or browse
          </p>
          <p className="text-sm text-muted-foreground">
            Multiple documents can be attached at once.
          </p>
        </div>
      </div>
    </Upload>
  );
}

Custom Trigger

Preview
Dark
import Upload from '@/components/ui/Upload';
import Button from '@/components/ui/Button';
import { PiUploadSimple } from 'react-icons/pi'

export default function CustomTriggerDemo() {
  return (
    <Upload>
      <Button variant="solid" icon={<PiUploadSimple />}>
        Select contract
      </Button>
    </Upload>
  );
}

Validation

Preview
Dark
Supported formats: PNG, JPEG. Maximum size: 500 KB.
import { useState } from 'react';
import Upload from '@/components/ui/Upload';
import Alert from '@/components/ui/Alert';

const MAX_SIZE = 500 * 1000;

export default function ValidationDemo() {
  const [message, setMessage] = useState('');

  const beforeUpload = (files: FileList | null) => {
    const allowed = ['image/png', 'image/jpeg'];

    if (!files) {
      return true;
    }

    for (const file of files) {
      if (!allowed.includes(file.type)) {
        return 'Only PNG and JPEG images are accepted.';
      }

      if (file.size > MAX_SIZE) {
        return 'Image must be smaller than 500 KB.';
      }
    }

    return true;
  };

  return (
    <div>
      <Upload
        accept="image/png,image/jpeg"
        uploadLimit={2}
        beforeUpload={beforeUpload}
        onBeforeUpload={(result) => setMessage(String(result))}
        onChange={(file) => setMessage(`${file.name} passed validation.`)}
        tip={
          <span className="text-muted-foreground mt-2">
            Supported formats: PNG, JPEG. Maximum size: 500 KB.
          </span>
        }
      />
      {message && (
        <Alert 
          showIcon 
          variant="destructive" 
          className="mt-2"
          onClose={() => setMessage('')}
        >
          {message}
        </Alert>
      )}
    </div>
  );
}

Avatar Image

Preview
Dark

Profile photo

import { useEffect, useState } from 'react';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Upload from '@/components/ui/Upload';
import { PiImageSquare, PiUser } from 'react-icons/pi'

export default function AvatarImageDemo() {
  const [preview, setPreview] = useState<string>();

  useEffect(() => {
    return () => {
      if (preview) {
        URL.revokeObjectURL(preview);
      }
    };
  }, [preview]);

  const beforeUpload = (files: FileList | null) => {
    const allowed = ['image/png', 'image/jpeg', 'image/gif'];

    if (!files) {
      return true;
    }

    for (const file of files) {
      if (!allowed.includes(file.type)) {
        return 'Upload PNG, JPEG, or GIF only.';
      }
    }

    return true;
  };

  const handleUpload = (file: File) => {
    const nextPreview = URL.createObjectURL(file);
    setPreview((current) => {
      if (current) {
        URL.revokeObjectURL(current);
      }

      return nextPreview;
    });
  };

  return (
    <div className="flex items-center gap-4">
      <Avatar
        size={72}
        src={preview}
        icon={<PiUser className="size-6" />}
        className={preview ? '' : 'border-2 border-dashed'}
      />
      <div className="grid gap-2">
        <p className="text-sm font-medium text-foreground">
          Profile photo
        </p>
        <Upload
          showList={false}
          uploadLimit={1}
          accept="image/png,image/jpeg,image/gif"
          beforeUpload={beforeUpload}
          onChange={handleUpload}
        >
          <Button size="sm" icon={<PiImageSquare />}>
            Upload image
          </Button>
        </Upload>
      </div>
    </div>
  );
}

Controlled

Preview
Dark

Controlled file count

2 files
PDF
Vendor-agreement.pdf
232 kb
ZIP
Invoice-export.zip
118 kb
import { useState } from 'react';
import Upload from '@/components/ui/Upload';
import Tag from '@/components/ui/Tag';

const createDemoFile = (name: string, size: number, type: string) =>
  ({ name, size, type }) as File;

const INITIAL_FILES = [
  createDemoFile('Vendor-agreement.pdf', 232000, 'application/pdf'),
  createDemoFile(
    'Invoice-export.zip',
    118000,
    'application/x-zip-compressed',
  ),
];

export default function ControlledDemo() {
  const [files, setFiles] = useState<File[]>(INITIAL_FILES);

  return (
    <div className="grid w-full max-w-xl gap-4">
      <div className="flex items-center justify-between gap-4">
        <p className="text-sm text-muted-foreground">
          Controlled file count
        </p>
        <Tag
          size="sm"
          className="border-transparent bg-palette-gray-soft text-palette-gray-soft-foreground"
        >
          {files.length} files
        </Tag>
      </div>
      <Upload
        multiple
        fileList={files}
        onChange={(_, fileList) => setFiles(fileList)}
        onFileRemove={(_, fileList) => setFiles(fileList)}
      />
    </div>
  );
}

Custom File Item

Preview
Dark

Recording.mp3

audio/mpeg4180 KB
TXT

Release-notes.txt

text/plain21 KB
ZIP

Archive.zip

application/x-zip-compressed1620 KB
import { useState } from 'react';
import Upload from '@/components/ui/Upload';
import Button from '@/components/ui/Button';
import CloseButton from '@/components/ui/CloseButton';
import Tag from '@/components/ui/Tag';

const createDemoFile = (name: string, size: number, type: string) =>
  ({ name, size, type }) as File;

const INITIAL_FILES = [
  createDemoFile('Recording.mp3', 4180000, 'audio/mpeg'),
  createDemoFile('Release-notes.txt', 21000, 'text/plain'),
  createDemoFile('Archive.zip', 1620000, 'application/x-zip-compressed'),
];

const formatSize = (size: number) => `${Math.round(size / 1000)} KB`;

export default function CustomFileItemDemo() {
  const [files, setFiles] = useState<File[]>(INITIAL_FILES);

  return (
    <Upload
      fileList={files}
      onChange={(_, fileList) => setFiles(fileList)}
      onFileRemove={(_, fileList) => setFiles(fileList)}
      fileListClass="w-full max-w-2xl rounded-card border"
      customFileItem={({ file, index, onRemove, FileIcon }) => {
        const isLast = index === files.length - 1;

        return (
          <div
            className={`flex items-center justify-between gap-4 p-4 ${
              isLast ? '' : 'border-b'
            }`}
          >
            <div className="flex min-w-0 items-center gap-4">
              <FileIcon file={file} />
              <div className="grid min-w-0 gap-2">
                <p className="truncate text-sm font-medium text-foreground">
                  {file.name}
                </p>
                <div className="flex flex-wrap items-center gap-2">
                  <Tag
                    size="sm"
                    className="border-transparent bg-palette-cyan-soft text-palette-cyan-soft-foreground"
                  >
                    {file.type || 'file'}
                  </Tag>
                  <span className="text-xs text-muted-foreground">
                    {formatSize(file.size)}
                  </span>
                </div>
              </div>
            </div>
            <CloseButton
              aria-label={`Remove ${file.name}`}
              onClick={onRemove}
            />
          </div>
        );
      }}
    />
  );
}

Disabled

Preview
Dark
Uploading is unavailable for archived records.
import Upload from '@/components/ui/Upload';

export default function DisabledDemo() {
  return (
    <div className="grid w-full max-w-xl gap-4">
      <Upload disabled />
      <Upload draggable disabled className="w-full">
        <div className="py-8 text-center text-sm text-muted-foreground">
          Uploading is unavailable for archived records.
        </div>
      </Upload>
    </div>
  );
}

API

Upload manages browser file selection and an in-memory list of selected files. It does not send files to a server by itself; use onChange, beforeUpload, and customFileItem to connect it to the workflow around it.

PropDescriptionTypeDefault
acceptFile accept string forwarded to the hidden file input.string-
beforeUploadSynchronously validates selected files before they are added to the list. Return false or a message string to block them.UploadBeforeUpload-
childrenCustom trigger content. Without children, Upload renders its default button or dropzone text.ReactNode-
customFileItemCustom renderer for each listed file. Receives the file, index, remove callback, and internal FileIcon renderer.UploadCustomFileItem-
disabledPrevents file selection and applies disabled styling.booleanfalse
draggableRenders a dashed drop area and enables drag-over feedback.booleanfalse
fileListControlled file list. When omitted, Upload stores selected files internally.File[]-
fileListClassClass names applied to the file-list wrapper.string-
fileItemClassClass names applied to default file rows.string-
multipleAllows selecting more than one file.boolean-
onBeforeUploadCalled when beforeUpload blocks a file, with false or the returned message.(result: boolean | string) => void-
onChangeCalled after a valid file is added. Receives the first selected file and the full list.UploadChangeHandler-
onFileRemoveCalled after a file is removed. Receives the removed file and the next list.UploadRemoveHandler-
showListShows the selected file list below the trigger.booleantrue
tipHint content rendered below the trigger.string | ReactNode-
uploadLimitLimit checked before adding another selection. When set to 1, the next valid upload replaces the current file.number-
classNameExtra classes for the root upload container.string-
refRef forwarded to the root upload container.Ref<HTMLDivElement>-
...nativePropsNative props forwarded through the root container type.UploadNativeProps-

Types

type UploadBeforeUpload = (
  files: FileList | null,
  fileList: File[],
) => boolean | string
 
type UploadChangeHandler = (file: File, fileList: File[]) => void
 
type UploadRemoveHandler = (file: File, fileList: File[]) => void
 
type UploadCustomFileItem = (props: {
  file: File
  index: number
  onRemove: () => void
  FileIcon: (props: FileIconProps) => JSX.Element
}) => ReactNode
 
type FileIconProps = {
  file: File
}
 
type UploadNativeProps = Omit<ComponentPropsWithRef<'div'>, 'onChange'>