Agents (llms.txt)
Octocat

File Upload

A drag-and-drop file picker with validation, previews, and progress rendering.

Drop files here or

Up to 5 files, 5 MB each

import { UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr';

import { FileUpload } from '@/components/file-upload';

export default function FileUploadPreview() {
  return (
    <FileUpload className="w-full max-w-sm" maxFiles={5} maxSize={5_000_000}>
      <FileUpload.Dropzone>
        <UploadSimpleIcon className="size-6 text-foreground-secondary" />
        <div className="text-sm">
          <span className="font-medium">Drop files here</span>{' '}
          <span className="text-foreground-secondary">or</span>{' '}
          <FileUpload.Trigger className="cursor-pointer font-medium text-accent underline-offset-2 hover:underline">
            browse
          </FileUpload.Trigger>
        </div>
        <p className="text-foreground-secondary text-xs">
          Up to 5 files, 5 MB each
        </p>
      </FileUpload.Dropzone>

      <FileUpload.List className="mt-3">
        {(files) =>
          files.map((entry) => (
            <FileUpload.Item key={entry.id} entry={entry}>
              <FileUpload.ItemPreview />
              <FileUpload.ItemName />
              <FileUpload.ItemSize />
              <FileUpload.ItemRemove />
            </FileUpload.Item>
          ))
        }
      </FileUpload.List>
    </FileUpload>
  );
}

Dependencies

Source Code

import {
  FileArchiveIcon,
  FileAudioIcon,
  FileIcon,
  FilePdfIcon,
  FileVideoIcon,
  ImageIcon,
  XIcon,
} from '@phosphor-icons/react/dist/ssr';
import {
  createContext,
  use,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';

type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';

interface FileEntry {
  id: string;
  file: File;
}

interface RejectedFileEntry extends FileEntry {
  error: string;
}

interface FileUploadContextValue {
  inputRef: React.RefObject<HTMLInputElement | null>;
  accept?: string;
  multiple: boolean;
  maxFiles?: number;
  disabled?: boolean;
  files: FileEntry[];
  rejected: RejectedFileEntry[];
  isDragging: boolean;
  isInvalid: boolean;
  // Internal: only Dropzone should call these. Kept on the public context for
  // simplicity rather than splitting into two contexts.
  setIsDragging: (v: boolean) => void;
  setIsInvalid: (v: boolean) => void;
  open: () => void;
  add: (files: FileList | File[]) => void;
  remove: (id: string) => void;
  clear: () => void;
}

const FileUploadContext = createContext<FileUploadContextValue | null>(null);

const useFileUploadContext = () => {
  const ctx = use(FileUploadContext);
  if (!ctx) {
    throw new Error(
      'FileUpload components must be used within a FileUpload root'
    );
  }
  return ctx;
};

const matchesAccept = (file: File, accept: string): boolean => {
  const tokens = accept
    .split(',')
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);
  if (tokens.length === 0) return true;

  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();

  return tokens.some((token) => {
    if (token.startsWith('.')) return name.endsWith(token);
    if (token.endsWith('/*')) return type.startsWith(token.slice(0, -1));
    return type === token;
  });
};

const formatBytes = (bytes: number): string => {
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.min(
    units.length - 1,
    Math.floor(Math.log(bytes) / Math.log(1024))
  );
  const value = bytes / 1024 ** i;
  return `${i === 0 ? value : value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
};

const getFileIcon = (file: File) => {
  const type = file.type.toLowerCase();
  if (type.startsWith('image/')) return ImageIcon;
  if (type.startsWith('video/')) return FileVideoIcon;
  if (type.startsWith('audio/')) return FileAudioIcon;
  if (type === 'application/pdf') return FilePdfIcon;
  if (
    type === 'application/zip' ||
    type === 'application/x-rar-compressed' ||
    type === 'application/x-7z-compressed' ||
    type === 'application/x-tar' ||
    type === 'application/gzip'
  ) {
    return FileArchiveIcon;
  }
  return FileIcon;
};

interface FileUploadProps
  extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange'> {
  accept?: string;
  multiple?: boolean;
  maxFiles?: number;
  maxSize?: number;
  minSize?: number;
  disabled?: boolean;
  required?: boolean;
  name?: string;
  validate?: (file: File) => string | undefined;
  onFilesChange?: (files: File[]) => void;
  /** Called with newly accepted entries when files are added. Use this to kick
   * off side effects (uploads, etc.) — running them off `onFilesChange` would
   * also fire on remove/clear. */
  onAdd?: (entries: FileEntry[]) => void;
  onReject?: (rejections: RejectedFileEntry[]) => void;
}

const FileUpload = ({
  accept,
  multiple = true,
  maxFiles,
  maxSize,
  minSize,
  disabled,
  required,
  name,
  validate,
  onFilesChange,
  onAdd,
  onReject,
  className,
  children,
  ...props
}: FileUploadProps) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const idCounterRef = useRef(0);
  const [files, setFiles] = useState<FileEntry[]>([]);
  const [rejected, setRejected] = useState<RejectedFileEntry[]>([]);
  const [isDragging, setIsDragging] = useState(false);
  const [isInvalid, setIsInvalid] = useState(false);

  const validateFile = useCallback(
    (file: File): string | undefined => {
      if (accept && !matchesAccept(file, accept)) return 'invalid-type';
      if (maxSize !== undefined && file.size > maxSize) return 'too-large';
      if (minSize !== undefined && file.size < minSize) return 'too-small';
      return validate?.(file);
    },
    [accept, maxSize, minSize, validate]
  );

  const open = useCallback(() => {
    if (disabled) return;
    inputRef.current?.click();
  }, [disabled]);

  const add = useCallback(
    (incoming: FileList | File[]) => {
      const incomingArr = Array.from(incoming);
      const accepted: FileEntry[] = [];
      const newRejected: RejectedFileEntry[] = [];

      const existingCount = multiple ? files.length : 0;

      for (const file of incomingArr) {
        const error = validateFile(file);
        if (error) {
          newRejected.push({
            id: `fu-${++idCounterRef.current}`,
            file,
            error,
          });
          continue;
        }

        if (
          maxFiles !== undefined &&
          existingCount + accepted.length >= maxFiles
        ) {
          newRejected.push({
            id: `fu-${++idCounterRef.current}`,
            file,
            error: 'too-many',
          });
          continue;
        }

        accepted.push({ id: `fu-${++idCounterRef.current}`, file });

        if (!multiple && accepted.length === 1) break;
      }

      if (accepted.length > 0) {
        const next = multiple ? [...files, ...accepted] : accepted;
        setFiles(next);
        onFilesChange?.(next.map((e) => e.file));
        onAdd?.(accepted);
      }

      setRejected(newRejected);
      if (newRejected.length > 0) onReject?.(newRejected);
    },
    [files, multiple, maxFiles, validateFile, onFilesChange, onAdd, onReject]
  );

  const remove = useCallback(
    (id: string) => {
      const next = files.filter((e) => e.id !== id);
      setFiles(next);
      onFilesChange?.(next.map((e) => e.file));
    },
    [files, onFilesChange]
  );

  const clear = useCallback(() => {
    setFiles([]);
    setRejected([]);
    onFilesChange?.([]);
  }, [onFilesChange]);

  const ctx = useMemo<FileUploadContextValue>(
    () => ({
      inputRef,
      accept,
      multiple,
      maxFiles,
      disabled,
      files,
      rejected,
      isDragging,
      isInvalid,
      setIsDragging,
      setIsInvalid,
      open,
      add,
      remove,
      clear,
    }),
    [
      accept,
      multiple,
      maxFiles,
      disabled,
      files,
      rejected,
      isDragging,
      isInvalid,
      open,
      add,
      remove,
      clear,
    ]
  );

  return (
    <FileUploadContext value={ctx}>
      <div className={cn('relative', className)} {...props}>
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          required={required}
          name={name}
          tabIndex={-1}
          aria-hidden="true"
          className="sr-only"
          onChange={(e) => {
            if (e.target.files && e.target.files.length > 0) {
              add(e.target.files);
            }
            e.target.value = '';
          }}
        />
        {children}
      </div>
    </FileUploadContext>
  );
};

interface FileUploadDropzoneProps extends React.ComponentPropsWithRef<'div'> {
  asChild?: boolean;
}

const FileUploadDropzone = ({
  asChild,
  className,
  children,
  onClick,
  onDragEnter,
  onDragLeave,
  onDragOver,
  onDrop,
  ...props
}: FileUploadDropzoneProps) => {
  const {
    open,
    add,
    disabled,
    isDragging,
    isInvalid,
    accept,
    setIsDragging,
    setIsInvalid,
  } = useFileUploadContext();
  const counterRef = useRef(0);

  // Best-effort drag preview: dataTransfer.items expose `kind` + `type` (no
  // file name yet), so we can short-circuit `data-invalid` on type-mismatched
  // drags. Fails safe — if items aren't available we just stay neutral.
  const willAccept = (e: React.DragEvent) => {
    if (!accept) return true;
    const items = e.dataTransfer?.items;
    if (!items || items.length === 0) return true;
    return Array.from(items).some((item) => {
      if (item.kind !== 'file') return false;
      const type = item.type.toLowerCase();
      const tokens = accept
        .split(',')
        .map((t) => t.trim().toLowerCase())
        .filter(Boolean);
      if (tokens.length === 0) return true;
      return tokens.some((token) => {
        if (token.startsWith('.')) return false;
        if (token.endsWith('/*')) return type.startsWith(token.slice(0, -1));
        return type === token;
      });
    });
  };

  const Comp = asChild ? Slot : 'div';

  return (
    <Comp
      data-dragging={isDragging || undefined}
      data-invalid={isInvalid || undefined}
      data-disabled={disabled || undefined}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        if (disabled) return;
        open();
      }}
      onDragEnter={(e) => {
        onDragEnter?.(e);
        if (e.defaultPrevented) return;
        e.preventDefault();
        counterRef.current++;
        if (counterRef.current === 1) {
          setIsDragging(true);
          setIsInvalid(!willAccept(e));
        }
      }}
      onDragLeave={(e) => {
        onDragLeave?.(e);
        if (e.defaultPrevented) return;
        e.preventDefault();
        counterRef.current = Math.max(0, counterRef.current - 1);
        if (counterRef.current === 0) {
          setIsDragging(false);
          setIsInvalid(false);
        }
      }}
      onDragOver={(e) => {
        onDragOver?.(e);
        if (e.defaultPrevented) return;
        e.preventDefault();
      }}
      onDrop={(e) => {
        onDrop?.(e);
        if (e.defaultPrevented) return;
        e.preventDefault();
        counterRef.current = 0;
        setIsDragging(false);
        setIsInvalid(false);
        if (disabled) return;
        if (e.dataTransfer.files.length > 0) {
          add(e.dataTransfer.files);
        }
      }}
      className={cn(
        'relative flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-border border-dashed p-6 text-center transition-colors',
        'cursor-pointer hover:bg-foreground/2',
        'data-dragging:border-accent data-dragging:bg-accent/5',
        'data-invalid:border-error data-invalid:bg-error/5',
        'data-disabled:pointer-events-none data-disabled:opacity-50',
        className
      )}
      {...props}
    >
      {children}
    </Comp>
  );
};

interface FileUploadTriggerProps extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

const FileUploadTrigger = ({
  asChild,
  onClick,
  children,
  ...props
}: FileUploadTriggerProps) => {
  const { open, disabled } = useFileUploadContext();
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      type="button"
      disabled={disabled}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        // Stop the click from bubbling to a parent Dropzone, which would
        // double-open the file picker.
        e.stopPropagation();
        open();
      }}
      {...props}
    >
      {children}
    </Comp>
  );
};

interface FileUploadListProps
  extends Omit<React.ComponentPropsWithRef<'ul'>, 'children'> {
  children: (files: FileEntry[]) => React.ReactNode;
  emptyFallback?: React.ReactNode;
}

const FileUploadList = ({
  className,
  children,
  emptyFallback,
  ...props
}: FileUploadListProps) => {
  const { files } = useFileUploadContext();

  if (files.length === 0 && emptyFallback === undefined) return null;

  return (
    <ul className={cn('flex flex-col gap-2', className)} {...props}>
      {files.length === 0 ? emptyFallback : children(files)}
    </ul>
  );
};

interface ItemContextValue {
  entry: FileEntry;
  status?: FileUploadStatus;
  progress?: number;
  error?: string;
}

const ItemContext = createContext<ItemContextValue | null>(null);

const useItemContext = () => {
  const ctx = use(ItemContext);
  if (!ctx) {
    throw new Error(
      'FileUpload.Item subcomponents must be used within a FileUpload.Item'
    );
  }
  return ctx;
};

interface FileUploadItemProps extends React.ComponentPropsWithRef<'li'> {
  entry: FileEntry;
  status?: FileUploadStatus;
  progress?: number;
  error?: string;
}

const FileUploadItem = ({
  entry,
  status,
  progress,
  error,
  className,
  children,
  ...props
}: FileUploadItemProps) => {
  const value = useMemo(
    () => ({ entry, status, progress, error }),
    [entry, status, progress, error]
  );

  return (
    <ItemContext value={value}>
      <li
        data-status={status}
        data-error={error ? true : undefined}
        className={cn(
          'flex items-center gap-3 rounded-xl border border-border bg-background p-2',
          'data-error:border-error/50',
          className
        )}
        {...props}
      >
        {children}
      </li>
    </ItemContext>
  );
};

interface FileUploadItemPreviewProps
  extends Omit<React.ComponentPropsWithRef<'div'>, 'children'> {
  iconClassName?: string;
}

const FileUploadItemPreview = ({
  className,
  iconClassName,
  ...props
}: FileUploadItemPreviewProps) => {
  const { entry } = useItemContext();

  const url = useMemo(() => {
    if (!entry.file.type.startsWith('image/')) return null;
    if (typeof URL?.createObjectURL !== 'function') return null;
    return URL.createObjectURL(entry.file);
  }, [entry.file]);

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

  const Icon = getFileIcon(entry.file);

  return (
    <div
      aria-hidden="true"
      className={cn(
        'flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-foreground/5 text-foreground/70',
        className
      )}
      {...props}
    >
      {url ? (
        <img src={url} alt="" className="size-full object-cover" />
      ) : (
        <Icon className={cn('size-5', iconClassName)} />
      )}
    </div>
  );
};

const FileUploadItemName = ({
  className,
  ...props
}: React.ComponentPropsWithRef<'span'>) => {
  const { entry } = useItemContext();
  return (
    <span
      className={cn('flex-1 truncate text-sm', className)}
      title={entry.file.name}
      {...props}
    >
      {entry.file.name}
    </span>
  );
};

const FileUploadItemSize = ({
  className,
  ...props
}: React.ComponentPropsWithRef<'span'>) => {
  const { entry } = useItemContext();
  return (
    <span
      className={cn('shrink-0 text-foreground-secondary text-xs', className)}
      {...props}
    >
      {formatBytes(entry.file.size)}
    </span>
  );
};

interface FileUploadItemProgressProps
  extends React.ComponentPropsWithRef<'div'> {
  showWhenIdle?: boolean;
}

const FileUploadItemProgress = ({
  className,
  showWhenIdle = false,
  ...props
}: FileUploadItemProgressProps) => {
  const { progress, status } = useItemContext();

  if (progress === undefined && !showWhenIdle) return null;
  if (status === 'success' && !showWhenIdle) return null;

  const value = Math.max(0, Math.min(100, progress ?? 0));

  return (
    <div
      role="progressbar"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={value}
      className={cn(
        'h-1 w-24 overflow-hidden rounded-full bg-foreground/10',
        className
      )}
      {...props}
    >
      <div
        className={cn(
          'h-full rounded-full bg-accent transition-[width] duration-150 ease-out',
          status === 'error' && 'bg-error'
        )}
        style={{ width: `${value}%` }}
      />
    </div>
  );
};

interface FileUploadItemRemoveProps
  extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

const FileUploadItemRemove = ({
  asChild,
  onClick,
  className,
  children,
  ...props
}: FileUploadItemRemoveProps) => {
  const { remove } = useFileUploadContext();
  const { entry } = useItemContext();
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      type="button"
      aria-label={`Remove ${entry.file.name}`}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        remove(entry.id);
      }}
      className={cn(
        !asChild &&
          'focus-visible:ring-(length:--ring-width) flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-foreground-secondary outline-none ring-ring transition-colors hover:bg-foreground/5 hover:text-foreground',
        className
      )}
      {...props}
    >
      {children ?? <XIcon className="size-4" />}
    </Comp>
  );
};

const CompoundFileUpload = Object.assign(FileUpload, {
  Dropzone: FileUploadDropzone,
  Trigger: FileUploadTrigger,
  List: FileUploadList,
  Item: FileUploadItem,
  ItemPreview: FileUploadItemPreview,
  ItemName: FileUploadItemName,
  ItemSize: FileUploadItemSize,
  ItemProgress: FileUploadItemProgress,
  ItemRemove: FileUploadItemRemove,
});

export type { FileEntry, FileUploadStatus, RejectedFileEntry };
export { CompoundFileUpload as FileUpload, formatBytes, useFileUploadContext };

Anatomy


          <FileUpload>
  <FileUpload.Dropzone>
    <FileUpload.Trigger />
  </FileUpload.Dropzone>
  <FileUpload.List>
    {(files) =>
      files.map((entry) => (
        <FileUpload.Item key={entry.id} entry={entry}>
          <FileUpload.ItemPreview />
          <FileUpload.ItemName />
          <FileUpload.ItemSize />
          <FileUpload.ItemProgress />
          <FileUpload.ItemRemove />
        </FileUpload.Item>
      ))
    }
  </FileUpload.List>
</FileUpload>
        

Features

  • Drag, click, and keyboard — all three input methods, no configuration needed
  • Validationaccept, maxFiles, maxSize, minSize, plus a custom validate function
  • Image previews — automatic object-URL thumbnails for images, MIME-grouped icons otherwise
  • Progress rendering — pass progress and status to each Item; the primitive renders the bar, you drive the value
  • Form-friendly — supports name, required, and disabled for submission as part of a form
  • Compound API — every part is replaceable, including the dropzone surface and trigger button

Scope

This primitive owns file selection and UI state. It does not own transport — the actual upload (XHR / fetch / S3 / tus) is the consumer’s job. This is intentional: every project’s upload backend is different, and bundling one would add weight and friction without solving the real work. See the Progress example for the recommended pattern.

API Reference

FileUpload

The root component. Manages selected and rejected file state, renders a hidden <input type="file">, and provides context to all subcomponents. Extends <div>.

Prop Default Type Description
accept - string Comma-separated list of allowed MIME types or extensions (e.g. `image/*,.pdf`).
multiple true boolean Whether multiple files can be selected.
maxFiles - number Maximum number of files allowed in the accepted list.
maxSize - number Maximum size per file in bytes.
minSize - number Minimum size per file in bytes.
disabled - boolean Disables the picker and all triggers.
required - boolean Marks the underlying input as required for form submission.
name - string Name of the underlying input for form submission.
validate - (file: File) => string | undefined Custom validator. Return an error key to reject the file, or undefined to accept it.
onFilesChange - (files: File[]) => void Called whenever the accepted file list changes (add, remove, clear).
onAdd - (entries: FileEntry[]) => void Called only when new files are accepted. Use this to start uploads or other side effects — `onFilesChange` would also fire on remove/clear.
onReject - (rejections: RejectedFileEntry[]) => void Called when files fail validation. Each entry includes the file and the error key (`invalid-type`, `too-large`, `too-small`, `too-many`, or any string returned by `validate`).

FileUpload.Dropzone

The drop target. Handles drag and drop, click-to-open, and exposes drag state via data-* attributes. Extends <div>.

AttributeValuesMeaning
data-draggingtrue | undefinedA drag is currently over the dropzone.
data-invalidtrue | undefinedThe dragged content does not match accept.
data-disabledtrue | undefinedThe upload is disabled.
Prop Default Type Description
asChild - boolean Whether to merge props onto the child element.

FileUpload.Trigger

A button that opens the file picker. Stops click propagation so it can be safely nested inside a Dropzone without double-opening the picker. Extends <button>.

Prop Default Type Description
asChild - boolean Whether to merge props onto the child element.

FileUpload.List

Renders the accepted file list. Uses a render-prop so the consumer can iterate and pass per-item props (status, progress, error). Extends <ul>.

Prop Default Type Description
children * - (files: FileEntry[]) => ReactNode Render function called with the current accepted files.
emptyFallback - ReactNode Rendered when there are no accepted files. Pass `null` to keep the list rendered but empty.

FileUpload.Item

A single accepted file. Provides item context to subcomponents. Extends <li>.

Prop Default Type Description
entry * - FileEntry The file entry for this row.
status - "idle" | "uploading" | "success" | "error" Optional upload status, surfaced as `data-status`.
progress - number Optional upload progress (0–100), used by `Item.Progress`.
error - string Optional error string. When set, the row gets `data-error` for styling.

FileUpload.ItemPreview

Renders an image thumbnail (via URL.createObjectURL) for image files, or a MIME-grouped icon otherwise. Object URLs are revoked on unmount. Extends <div>.

FileUpload.ItemName

Renders the file’s name with truncation and a tooltip. Extends <span>.

FileUpload.ItemSize

Renders the file’s size formatted as B, KB, MB, etc. Extends <span>.

FileUpload.ItemProgress

Renders a progress bar reflecting the parent Item’s progress prop. Returns null when no progress is set unless showWhenIdle is true. Switches to the error color when status === 'error'. Extends <div>.

Prop Default Type Description
showWhenIdle false boolean Render the bar even when no `progress` is set.

FileUpload.ItemRemove

A button that removes the parent Item from the accepted list. Extends <button>.

Prop Default Type Description
asChild - boolean Whether to merge props onto the child element.

useFileUploadContext

Hook for reading FileUpload state from any descendant. Useful for rendering rejection lists, syncing upload tracking, or building custom subcomponents.


          const { files, rejected, isDragging, remove, clear, open } = useFileUploadContext();
        

Examples

Default

Drag-and-drop with browse button, validation, image previews, and removable items.

Drop files here or

Up to 5 files, 5 MB each

import { UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr';

import { FileUpload } from '@/components/file-upload';

export default function FileUploadPreview() {
  return (
    <FileUpload className="w-full max-w-sm" maxFiles={5} maxSize={5_000_000}>
      <FileUpload.Dropzone>
        <UploadSimpleIcon className="size-6 text-foreground-secondary" />
        <div className="text-sm">
          <span className="font-medium">Drop files here</span>{' '}
          <span className="text-foreground-secondary">or</span>{' '}
          <FileUpload.Trigger className="cursor-pointer font-medium text-accent underline-offset-2 hover:underline">
            browse
          </FileUpload.Trigger>
        </div>
        <p className="text-foreground-secondary text-xs">
          Up to 5 files, 5 MB each
        </p>
      </FileUpload.Dropzone>

      <FileUpload.List className="mt-3">
        {(files) =>
          files.map((entry) => (
            <FileUpload.Item key={entry.id} entry={entry}>
              <FileUpload.ItemPreview />
              <FileUpload.ItemName />
              <FileUpload.ItemSize />
              <FileUpload.ItemRemove />
            </FileUpload.Item>
          ))
        }
      </FileUpload.List>
    </FileUpload>
  );
}

Without dropzone

Use a Trigger on its own when a drop area would be visually heavy or out of place — for example, a single-image picker beside a preview.

PNG or JPG, 5 MB max

import { CameraIcon } from '@phosphor-icons/react/dist/ssr';
import { useEffect, useMemo, useState } from 'react';

import { Avatar } from '@/components/avatar';
import { Button } from '@/components/button';
import { FileUpload } from '@/components/file-upload';

export default function FileUploadWithoutDropzonePreview() {
  const [file, setFile] = useState<File | null>(null);

  const url = useMemo(() => {
    if (!file) return null;
    return URL.createObjectURL(file);
  }, [file]);

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

  return (
    <FileUpload
      multiple={false}
      accept="image/*"
      maxSize={5_000_000}
      onFilesChange={(files) => setFile(files[0] ?? null)}
    >
      <div className="flex items-center gap-4">
        <Avatar size="2xl">
          {url ? <Avatar.Image src={url} /> : <CameraIcon className="size-6" />}
        </Avatar>
        <div className="flex flex-col gap-1">
          <FileUpload.Trigger asChild>
            <Button variant="outline" size="sm">
              {file ? 'Change avatar' : 'Upload avatar'}
            </Button>
          </FileUpload.Trigger>
          <p className="text-foreground-secondary text-xs">
            PNG or JPG, 5 MB max
          </p>
        </div>
      </div>
    </FileUpload>
  );
}

With progress

The recommended pattern for tracking real uploads: kick off transport from onAdd, store per-id state in the parent, and pass status + progress back to each Item. The primitive renders the bar; the consumer drives the value via XHR’s upload.onprogress event. fetch does not expose upload progress reliably across browsers — use XMLHttpRequest.

Drop or browse

import { UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr';
import { useState } from 'react';

import {
  type FileEntry,
  FileUpload,
  type FileUploadStatus,
} from '@/components/file-upload';

interface UploadState {
  status: FileUploadStatus;
  progress: number;
}

/**
 * Real-world replacement for `simulateUpload`:
 *
 *   const xhr = new XMLHttpRequest();
 *   xhr.upload.addEventListener('progress', (e) => {
 *     if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
 *   });
 *   xhr.addEventListener('load', () => onDone(xhr.status === 200));
 *   xhr.open('POST', '/api/upload');
 *   xhr.send(formData);
 *
 * `fetch` does not expose upload progress reliably across browsers — XHR is
 * still the right tool here.
 */
const simulateUpload = (
  onProgress: (value: number) => void,
  onDone: (success: boolean) => void
) => {
  let progress = 0;
  const interval = setInterval(() => {
    progress += Math.random() * 18;
    if (progress >= 100) {
      onProgress(100);
      clearInterval(interval);
      onDone(Math.random() > 0.15);
      return;
    }
    onProgress(progress);
  }, 200);
};

export default function FileUploadProgressPreview() {
  const [uploads, setUploads] = useState<Record<string, UploadState>>({});

  const startUploads = (entries: FileEntry[]) => {
    for (const entry of entries) {
      setUploads((prev) => ({
        ...prev,
        [entry.id]: { status: 'uploading', progress: 0 },
      }));

      simulateUpload(
        (progress) => {
          setUploads((prev) => ({
            ...prev,
            [entry.id]: { status: 'uploading', progress },
          }));
        },
        (success) => {
          setUploads((prev) => ({
            ...prev,
            [entry.id]: {
              status: success ? 'success' : 'error',
              progress: 100,
            },
          }));
        }
      );
    }
  };

  return (
    <FileUpload className="w-full max-w-sm" maxFiles={5} onAdd={startUploads}>
      <FileUpload.Dropzone>
        <UploadSimpleIcon className="size-6 text-foreground-secondary" />
        <p className="text-sm">
          <span className="font-medium">Drop or browse</span>
        </p>
      </FileUpload.Dropzone>

      <FileUpload.List className="mt-3">
        {(files) =>
          files.map((entry) => {
            const state = uploads[entry.id];
            return (
              <FileUpload.Item
                key={entry.id}
                entry={entry}
                status={state?.status}
                progress={state?.progress}
                error={state?.status === 'error' ? 'Upload failed' : undefined}
              >
                <FileUpload.ItemPreview />
                <div className="flex min-w-0 flex-1 flex-col gap-1">
                  <FileUpload.ItemName />
                  <FileUpload.ItemProgress className="w-full" />
                </div>
                <FileUpload.ItemRemove />
              </FileUpload.Item>
            );
          })
        }
      </FileUpload.List>
    </FileUpload>
  );
}

Validation errors

Shows rejected files alongside their reason. The onReject callback gives you the offending files and the error key (invalid-type, too-large, too-small, too-many, or your own from validate).

PNG or JPG only

Up to 3 files, 977 KB each

import {
  UploadSimpleIcon,
  WarningCircleIcon,
} from '@phosphor-icons/react/dist/ssr';
import { useState } from 'react';

import {
  FileUpload,
  formatBytes,
  type RejectedFileEntry,
} from '@/components/file-upload';

const ERROR_LABEL: Record<string, string> = {
  'invalid-type': 'Wrong file type',
  'too-large': 'File too large',
  'too-small': 'File too small',
  'too-many': 'Too many files',
};

const MAX_SIZE = 1_000_000;

export default function FileUploadErrorsPreview() {
  const [errors, setErrors] = useState<RejectedFileEntry[]>([]);

  return (
    <FileUpload
      className="w-full max-w-sm"
      accept="image/png,image/jpeg"
      maxSize={MAX_SIZE}
      maxFiles={3}
      onReject={setErrors}
      onFilesChange={() => setErrors([])}
    >
      <FileUpload.Dropzone>
        <UploadSimpleIcon className="size-6 text-foreground-secondary" />
        <p className="font-medium text-sm">PNG or JPG only</p>
        <p className="text-foreground-secondary text-xs">
          Up to 3 files, {formatBytes(MAX_SIZE)} each
        </p>
      </FileUpload.Dropzone>

      {errors.length > 0 && (
        <ul className="mt-3 flex flex-col gap-1">
          {errors.map((rejection) => (
            <li
              key={rejection.id}
              className="flex items-center gap-2 rounded-lg bg-error/10 px-3 py-2 text-error text-sm"
            >
              <WarningCircleIcon className="size-4 shrink-0" />
              <span className="flex-1 truncate">{rejection.file.name}</span>
              <span className="shrink-0 text-xs">
                {ERROR_LABEL[rejection.error] ?? rejection.error}
              </span>
            </li>
          ))}
        </ul>
      )}

      <FileUpload.List className="mt-3">
        {(files) =>
          files.map((entry) => (
            <FileUpload.Item key={entry.id} entry={entry}>
              <FileUpload.ItemPreview />
              <FileUpload.ItemName />
              <FileUpload.ItemSize />
              <FileUpload.ItemRemove />
            </FileUpload.Item>
          ))
        }
      </FileUpload.List>
    </FileUpload>
  );
}

Wiring to a real backend

The primitive deliberately stops at file selection. To wire it to a real upload endpoint:


          const upload = (file: File, onProgress: (n: number) => void) => {
  return new Promise<void>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
    });
    xhr.addEventListener("load", () =>
      xhr.status >= 200 && xhr.status < 300 ? resolve() : reject()
    );
    xhr.addEventListener("error", () => reject());

    const form = new FormData();
    form.append("file", file);
    xhr.open("POST", "/api/upload");
    xhr.send(form);
  });
};
        

For S3, the same shape works against a pre-signed URL — replace the URL and use PUT with the file directly as the body. For resumable uploads (tus), a multi-GB workflow, or remote sources (Drive, Dropbox), reach for Uppy instead of extending this primitive.

Best Practices

  1. Keep transport out of the component. Track upload state in the parent and pass progress / status down. This keeps the primitive reusable across very different backends.
  2. Use XHR, not fetch, for progress. Browser support for fetch upload streams is still uneven in 2026.
  3. Validate on both ends. accept and maxSize only filter at the picker level — always validate on the server too.
  4. Reset upload tracking on remove. When a user removes a file mid-upload, abort the request (xhr.abort()) to avoid wasted bandwidth.

Previous

Field

Next

Input