Skip to content

File Upload

File selection built on a real file input: click, keyboard or drag and drop, with type, size and count checks, a list of selected files and clear error messages. It selects files; uploading them is up to you.

Choose a file or drag it here. Accepted: .pdf,.doc,.docx · Up to 5 MB

Installation

Add File Upload to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add file-upload
Adds
FileUpload.tsxField.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-label@radix-ui/react-slotreact-iconsif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install react-icons @radix-ui/react-slot @radix-ui/react-label

Add the Field component

FileUpload imports ./Field, so add this file next to it

/components/ui/Field.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";

/*
 * Field wires a label, description and message to one form control:
 *   Field.Label        → <label for={controlId}>
 *   Field.Description  → referenced by aria-describedby
 *   Field.Message      → referenced by aria-describedby (error text when invalid)
 *   invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
 * Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
 * useFieldControlProps. It is plain React state: it works with native forms and form libraries.
 */

type FieldContextValue = {
    controlId: string;
    labelId: string;
    descriptionId: string;
    messageId: string;
    invalid: boolean;
    required: boolean;
    disabled: boolean;
    hasDescription: boolean;
    hasMessage: boolean;
    setHasDescription: (value: boolean) => void;
    setHasMessage: (value: boolean) => void;
};

const FieldContext = React.createContext<FieldContextValue | null>(null);

export function useField() {
    return React.useContext(FieldContext);
}

/**
 * Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
 * this lets each Field.Root reflect that state (label styling, `disabled` on the control).
 */
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);

export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Id for the control; generated when omitted. */
    id?: string;
    invalid?: boolean;
    required?: boolean;
    disabled?: boolean;
}

const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
    const fieldset = React.useContext(FieldsetContext);
    const disabled = disabledProp || Boolean(fieldset?.disabled);
    const generatedId = React.useId();
    const controlId = id ?? `field-${generatedId}`;
    const [hasDescription, setHasDescription] = React.useState(false);
    const [hasMessage, setHasMessage] = React.useState(false);

    const value = React.useMemo<FieldContextValue>(
        () => ({
            controlId,
            labelId: `${controlId}-label`,
            descriptionId: `${controlId}-description`,
            messageId: `${controlId}-message`,
            invalid,
            required,
            disabled,
            hasDescription,
            hasMessage,
            setHasDescription,
            setHasMessage,
        }),
        [controlId, invalid, required, disabled, hasDescription, hasMessage]
    );

    return (
        <FieldContext.Provider value={value}>
            <div
                ref={ref}
                className={cn("flex flex-col gap-1.5", className)}
                data-invalid={invalid || undefined}
                data-disabled={disabled || undefined}
                {...props}
            >
                {children}
            </div>
        </FieldContext.Provider>
    );
});
Root.displayName = "Field.Root";

type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;

const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
    const field = useField();

    return (
        <Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
            {children}
            {field?.required && <Label.Asterisk />}
        </Label.Root>
    );
});
FieldLabel.displayName = "Field.Label";

const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
    const field = useField();
    const setHasDescription = field?.setHasDescription;

    React.useEffect(() => {
        if (!setHasDescription) return;
        setHasDescription(true);
        return () => setHasDescription(false);
    }, [setHasDescription]);

    return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";

export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
    /** Only render while the field is invalid (default true). */
    showWhenValid?: boolean;
}

const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
    const field = useField();
    const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
    const setHasMessage = field?.setHasMessage;

    React.useEffect(() => {
        if (!setHasMessage || !visible) return;
        setHasMessage(true);
        return () => setHasMessage(false);
    }, [setHasMessage, visible]);

    if (!visible) return null;

    return (
        <p
            ref={ref}
            id={field?.messageId}
            aria-live="polite"
            className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
            {...props}
        >
            {children}
        </p>
    );
});
Message.displayName = "Field.Message";

type ControlAttributes = {
    id?: string;
    "aria-describedby"?: string;
    "aria-invalid"?: React.AriaAttributes["aria-invalid"];
    "aria-required"?: React.AriaAttributes["aria-required"];
    required?: boolean;
    disabled?: boolean;
};

/**
 * Merges Field context into a control's props. Explicit props win; describedby ids are combined.
 */
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
    const field = useField();
    const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");

    const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
        .filter(Boolean)
        .join(" ");

    return {
        ...props,
        id: props.id ?? field?.controlId,
        "aria-describedby": describedBy || undefined,
        "aria-invalid": invalid || undefined,
        required: props.required ?? (field?.required || undefined),
        disabled: props.disabled ?? (field?.disabled || undefined),
    };
}

/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
    const controlProps = useFieldControlProps(props);
    return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";

export { Root, FieldLabel as Label, Description, Message, Control };

Add the Label component

FileUpload imports ./Label, so add this file next to it

/components/ui/Label.tsx
"use client";

import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";

export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
    /** Dims the label. The control itself must also be disabled. */
    disabled?: boolean;
}

const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
    return (
        <LabelPrimitive.Root
            ref={ref}
            data-disabled={disabled || undefined}
            className={cn(
                "group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
                "data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
                className
            )}
            {...props}
        />
    );
});
Root.displayName = "Label.Root";

// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
    <span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
        {children || "*"}
    </span>
));
Asterisk.displayName = "Label.Asterisk";

const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
    <span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";

export { Root, Asterisk, Sub };

Create or update your FileUpload component

Copy this code to create your FileUpload component

/components/ui/FileUpload.tsx
"use client";

import * as React from "react";
import { RiCloseLine, RiFileLine, RiUploadCloud2Line } from "react-icons/ri";
import { cn } from "@/lib/utils";
import { useFieldControlProps } from "./Field";

/*
 * Client-side file selection (no uploading, no reading file contents).
 *   FileUpload.Dropzone holds a real, focusable <input type="file">: Tab to it and press Enter/Space, or click
 *   anywhere in the zone, to open the system picker. Dropping files is an extra, never the only way.
 *   Files are checked against `accept`, `maxSize` and `maxFiles`; rejected files are listed by FileUpload.Errors.
 *   Additions, removals and rejections are announced once through a polite status region.
 * Read the selection from `onValueChange` and upload it yourself. With `name`, the browser also submits the
 * selected files with a native form where it supports setting them (DataTransfer).
 */

export type FileRejectionReason = "file-type" | "file-too-large" | "too-many-files";
export interface FileRejection {
    file: File;
    reasons: FileRejectionReason[];
}

/** "1.2 MB" style sizes (base 1024). */
export function formatBytes(bytes: number) {
    if (!Number.isFinite(bytes) || bytes < 0) return "";
    if (bytes < 1024) return `${bytes} B`;
    const units = ["KB", "MB", "GB", "TB"];
    let size = bytes / 1024;
    let unit = 0;
    while (size >= 1024 && unit < units.length - 1) {
        size /= 1024;
        unit += 1;
    }
    return `${size >= 10 ? Math.round(size) : Math.round(size * 10) / 10} ${units[unit]}`;
}

/** Same rules as the native `accept` attribute: ".pdf", "image/*", "application/pdf". */
export function isFileAccepted(file: File, accept: string | undefined) {
    if (!accept) return true;
    const name = file.name.toLowerCase();
    const type = (file.type || "").toLowerCase();
    return accept
        .split(",")
        .map((token) => token.trim().toLowerCase())
        .filter(Boolean)
        .some((token) => {
            if (token.startsWith(".")) return name.endsWith(token);
            if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
            return type === token;
        });
}

const REASON_TEXT: Record<FileRejectionReason, string> = {
    "file-type": "file type not allowed",
    "file-too-large": "file is too large",
    "too-many-files": "too many files",
};

const sameFile = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;

type FileUploadContextValue = {
    files: File[];
    rejections: FileRejection[];
    inputId: string;
    hintId: string;
    errorsId: string;
    accept?: string;
    multiple: boolean;
    maxSize?: number;
    disabled: boolean;
    invalid: boolean;
    required: boolean;
    name?: string;
    form?: string;
    inputRef: React.MutableRefObject<HTMLInputElement | null>;
    addFiles: (incoming: File[]) => void;
    removeFile: (file: File) => void;
};

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

function useFileUpload(part: string) {
    const context = React.useContext(FileUploadContext);
    if (!context) throw new Error(`<FileUpload.${part}> must be rendered inside <FileUpload.Root>.`);
    return context;
}

export interface FileUploadRootProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
    /** Selected files (controlled). */
    value?: File[];
    defaultValue?: File[];
    onValueChange?: (files: File[]) => void;
    /** Called with the files that failed validation in the latest selection. */
    onReject?: (rejections: FileRejection[]) => void;
    /** Allowed types, as in the native attribute: "image/*,.pdf". */
    accept?: string;
    multiple?: boolean;
    /** Maximum size per file, in bytes. */
    maxSize?: number;
    /** Maximum number of files in total. */
    maxFiles?: number;
    disabled?: boolean;
    invalid?: boolean;
    /** At least one file must be selected before the form submits. */
    required?: boolean;
    name?: string;
    form?: string;
    /** Id of the file input (Field.Root provides one). */
    inputId?: string;
}

const Root = React.forwardRef<HTMLDivElement, FileUploadRootProps>(
    (
        { value, defaultValue, onValueChange, onReject, accept, multiple = false, maxSize, maxFiles, disabled, invalid, required, name, form, inputId, className, children, ...props },
        ref
    ) => {
        const [uncontrolled, setUncontrolled] = React.useState<File[]>(defaultValue ?? []);
        const isControlled = value !== undefined;
        const files = isControlled ? value : uncontrolled;
        const [rejections, setRejections] = React.useState<FileRejection[]>([]);
        const [announcement, setAnnouncement] = React.useState("");
        const [missingRequired, setMissingRequired] = React.useState(false);
        const inputRef = React.useRef<HTMLInputElement | null>(null);
        const baseId = React.useId();

        const field = useFieldControlProps({ id: inputId, disabled, required }, { invalid: invalid || (missingRequired && files.length === 0) });
        const resolvedInputId = field.id ?? `${baseId}-input`;

        const commit = React.useCallback(
            (next: File[], message: string) => {
                if (!isControlled) setUncontrolled(next);
                onValueChange?.(next);
                setAnnouncement(message);
                if (next.length > 0) setMissingRequired(false);
            },
            [isControlled, onValueChange]
        );

        const addFiles = React.useCallback(
            (incoming: File[]) => {
                const accepted: File[] = [];
                const rejected: FileRejection[] = [];
                const base = multiple ? files : [];
                const limit = multiple ? (maxFiles ?? Infinity) : 1;

                for (const file of incoming) {
                    if (base.some((existing) => sameFile(existing, file)) || accepted.some((existing) => sameFile(existing, file))) continue;
                    const reasons: FileRejectionReason[] = [];
                    if (!isFileAccepted(file, accept)) reasons.push("file-type");
                    if (maxSize !== undefined && file.size > maxSize) reasons.push("file-too-large");
                    if (reasons.length === 0 && base.length + accepted.length >= limit) reasons.push("too-many-files");
                    if (reasons.length) rejected.push({ file, reasons });
                    else accepted.push(file);
                }

                setRejections(rejected);
                if (rejected.length) onReject?.(rejected);

                const parts: string[] = [];
                if (accepted.length) parts.push(accepted.length === 1 ? `Added ${accepted[0].name}.` : `Added ${accepted.length} files.`);
                if (rejected.length) parts.push(rejected.map((entry) => `${entry.file.name} rejected: ${entry.reasons.map((reason) => REASON_TEXT[reason]).join(", ")}.`).join(" "));
                const message = parts.join(" ");

                if (accepted.length) commit([...base, ...accepted], message);
                else setAnnouncement(message);
            },
            [files, multiple, maxFiles, maxSize, accept, onReject, commit]
        );

        const removeFile = React.useCallback(
            (file: File) => {
                setRejections([]);
                commit(
                    files.filter((entry) => entry !== file),
                    `Removed ${file.name}.`
                );
            },
            [files, commit]
        );

        // Keep the native input's FileList in sync so a native form submits the selection (where supported).
        React.useEffect(() => {
            const input = inputRef.current;
            if (!input || typeof DataTransfer === "undefined") return;
            try {
                const transfer = new DataTransfer();
                files.forEach((file) => transfer.items.add(file));
                input.files = transfer.files;
            } catch {
                // Browsers without a writable FileList: the selection is still available through onValueChange.
            }
        }, [files]);

        // Clear errors when the form is reset.
        React.useEffect(() => {
            const formElement = inputRef.current?.form;
            if (!formElement) return;
            const onReset = () => {
                setRejections([]);
                setMissingRequired(false);
                if (!isControlled) setUncontrolled(defaultValue ?? []);
            };
            formElement.addEventListener("reset", onReset);
            return () => formElement.removeEventListener("reset", onReset);
        }, [isControlled, defaultValue]);

        const context: FileUploadContextValue = {
            files,
            rejections,
            inputId: resolvedInputId,
            hintId: `${baseId}-hint`,
            errorsId: `${baseId}-errors`,
            accept,
            multiple,
            maxSize,
            disabled: Boolean(field.disabled),
            invalid: Boolean(field["aria-invalid"]) || rejections.length > 0,
            required: Boolean(field.required),
            name,
            form,
            inputRef,
            addFiles,
            removeFile,
        };

        return (
            <FileUploadContext.Provider value={context}>
                <div ref={ref} data-disabled={context.disabled || undefined} className={cn("relative flex w-full flex-col gap-3", className)} {...props}>
                    {children}
                    <p role="status" className="sr-only">
                        {announcement}
                    </p>
                    {context.required && (
                        <input
                            tabIndex={-1}
                            aria-hidden="true"
                            form={form}
                            value={files.length ? String(files.length) : ""}
                            required
                            disabled={context.disabled}
                            onChange={() => {}}
                            onInvalid={(event) => {
                                event.preventDefault();
                                setMissingRequired(true);
                                inputRef.current?.focus();
                            }}
                            className="pointer-events-none absolute left-0 top-0 size-px opacity-0"
                        />
                    )}
                </div>
            </FileUploadContext.Provider>
        );
    }
);
Root.displayName = "FileUpload.Root";

export interface FileUploadDropzoneProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Instructions shown in the zone and used as the input's description. */
    children?: React.ReactNode;
}

const Dropzone = React.forwardRef<HTMLDivElement, FileUploadDropzoneProps>(({ className, children, onClick, ...props }, ref) => {
    const context = useFileUpload("Dropzone");
    const [dragging, setDragging] = React.useState(false);
    const controlProps = useFieldControlProps({ id: context.inputId, "aria-describedby": context.hintId + (context.rejections.length ? ` ${context.errorsId}` : "") });

    const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
        event.preventDefault();
        setDragging(false);
        if (context.disabled) return;
        context.addFiles(Array.from(event.dataTransfer.files ?? []));
    };

    return (
        <div
            ref={ref}
            data-dragging={dragging || undefined}
            data-invalid={context.invalid || undefined}
            data-disabled={context.disabled || undefined}
            onClick={(event) => {
                onClick?.(event);
                if (event.defaultPrevented || context.disabled) return;
                if (event.target !== context.inputRef.current) context.inputRef.current?.click();
            }}
            onDragEnter={(event) => {
                event.preventDefault();
                if (!context.disabled) setDragging(true);
            }}
            onDragOver={(event) => {
                event.preventDefault();
                if (event.dataTransfer) event.dataTransfer.dropEffect = context.disabled ? "none" : "copy";
            }}
            onDragLeave={(event) => {
                if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
            }}
            onDrop={onDrop}
            className={cn(
                "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-input bg-transparent px-6 py-8 text-center transition-colors duration-base",
                "hover:bg-muted data-[dragging]:border-primary data-[dragging]:bg-primary-subtle",
                // Field focus: strong border plus a soft halo outside it (keyboard focus on the hidden file input).
                "outline outline-0 outline-offset-2 outline-accent has-[input:focus-visible]:border-foreground has-[input:focus-visible]:outline-2",
                "data-[invalid]:border-destructive",
                "data-[disabled]:cursor-not-allowed data-[disabled]:bg-muted data-[disabled]:opacity-60 data-[disabled]:hover:bg-muted",
                className
            )}
            {...props}
        >
            <RiUploadCloud2Line aria-hidden="true" className="size-8 text-muted-foreground" />
            <div id={context.hintId} className="flex flex-col gap-1 size-p-sm text-muted-foreground">
                {children ?? (
                    <>
                        <span>
                            <span className="font-medium text-foreground">Choose {context.multiple ? "files" : "a file"}</span> or drag {context.multiple ? "them" : "it"} here.
                        </span>{" "}
                        {(context.accept || context.maxSize !== undefined) && (
                            <span className="size-p-xs">
                                {[context.accept && `Accepted: ${context.accept}`, context.maxSize !== undefined && `Up to ${formatBytes(context.maxSize)}`].filter(Boolean).join(" · ")}
                            </span>
                        )}
                    </>
                )}
            </div>
            <input
                ref={context.inputRef}
                type="file"
                id={controlProps.id}
                name={context.name}
                form={context.form}
                accept={context.accept}
                multiple={context.multiple}
                disabled={context.disabled}
                aria-describedby={controlProps["aria-describedby"]}
                aria-invalid={context.invalid || undefined}
                aria-required={context.required || undefined}
                onClick={(event) => event.stopPropagation()}
                onChange={(event) => {
                    const selection = Array.from(event.target.files ?? []);
                    // Clear so choosing the same file again fires change; the synced FileList is restored by Root.
                    if (typeof DataTransfer === "undefined") event.target.value = "";
                    context.addFiles(selection);
                }}
                className="sr-only"
            />
        </div>
    );
});
Dropzone.displayName = "FileUpload.Dropzone";

export interface FileUploadListProps extends React.HTMLAttributes<HTMLUListElement> {
    /** Accessible name prefix for remove buttons ("Remove <file name>"). */
    removeLabel?: string;
}

const List = React.forwardRef<HTMLUListElement, FileUploadListProps>(({ className, removeLabel = "Remove", ...props }, ref) => {
    const context = useFileUpload("List");
    const listRef = React.useRef<HTMLUListElement | null>(null);
    if (context.files.length === 0) return null;

    const remove = (file: File, index: number) => {
        // Keep focus on a remove button (next, else previous), or return it to the file input.
        const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>("button[data-remove]") ?? []);
        const next = buttons[index + 1] ?? buttons[index - 1];
        context.removeFile(file);
        requestAnimationFrame(() => {
            if (next?.isConnected) next.focus();
            else context.inputRef.current?.focus();
        });
    };

    return (
        <ul
            ref={(node) => {
                listRef.current = node;
                if (typeof ref === "function") ref(node);
                else if (ref) ref.current = node;
            }}
            aria-label="Selected files"
            className={cn("flex flex-col gap-2", className)}
            {...props}
        >
            {context.files.map((file, index) => (
                <li key={`${file.name}-${file.size}-${file.lastModified}`} className="flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2">
                    <RiFileLine aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
                    <span className="flex min-w-0 flex-1 flex-col">
                        <span className="truncate size-label-sm text-foreground">{file.name}</span>
                        <span className="size-p-xs text-muted-foreground">{formatBytes(file.size)}</span>
                    </span>
                    <button
                        type="button"
                        data-remove=""
                        aria-label={`${removeLabel} ${file.name}`}
                        disabled={context.disabled}
                        onClick={() => remove(file, index)}
                        className="flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-fast hover:bg-muted hover:text-foreground focus-ring disabled:pointer-events-none disabled:opacity-50"
                    >
                        <RiCloseLine aria-hidden="true" className="size-4" />
                    </button>
                </li>
            ))}
        </ul>
    );
});
List.displayName = "FileUpload.List";

/** Lists the files rejected by the latest selection. Visual only: the rejection is announced by Root. */
const Errors = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(({ className, ...props }, ref) => {
    const context = useFileUpload("Errors");
    if (context.rejections.length === 0) return null;

    return (
        <ul ref={ref} id={context.errorsId} className={cn("flex flex-col gap-1 size-p-xs text-destructive-strong", className)} {...props}>
            {context.rejections.map(({ file, reasons }) => (
                <li key={`${file.name}-${file.size}-${file.lastModified}`}>
                    {file.name}: {reasons.map((reason) => REASON_TEXT[reason]).join(", ")}
                </li>
            ))}
        </ul>
    );
});
Errors.displayName = "FileUpload.Errors";

export { Root, Dropzone, List, Errors };

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

Multiple Files

multiple adds to the selection; maxFiles and maxSize reject the rest with a reason. Custom instructions replace the default text.

Up to 4 images, 2 MB each.

Choose images or drop them herePNG, JPG, WebP · 0/4 selected

Required in a Form

A required selection blocks submission and moves focus to the file input. Read the files from onValueChange when the form submits.

Choose a file or drag it here. Accepted: application/pdf · Up to 1 MB

Disabled

A disabled Field disables selection and dropping.

Choose files or drag them here.

Attachments are disabled for archived tickets.

Scope and Security

  • FileUpload never uploads, stores or reads file contents. It hands you File objects; send them to your own endpoint.
  • accept and maxSize are user-experience checks in the browser. Validate type and size again on the server.
  • With name, the selection is also written to the native input (where the browser allows it) so a plain form post includes the files.

Accessibility

  • The control is a native <input type="file">, labelled by Field.Label and described by the instructions. Tab to it and press Enter or Space to open the picker; the dropzone shows the focus ring.
  • Drag and drop is optional: everything works without it.
  • Additions, removals and rejected files are announced once through a polite status message; rejected files are also listed under the zone and referenced by the input's aria-describedby.
  • Each remove button is named "Remove <file name>"; after removing, focus moves to the next file or back to the input.

API Reference

Import with import * as FileUpload from "@/components/ui/FileUpload".

FileUpload.Root

PropTypeDefaultDescription
value / defaultValue"File[]""[]"

Selected files.

onValueChange"(files: File[]) => void""undefined"

Called after files are added or removed.

onReject"(rejections) => void""undefined"

Files that failed validation, with reasons.

accept"string""any"

Native accept syntax, e.g. "image/*,.pdf".

multiple"boolean""false"

Add to the selection instead of replacing it.

maxSize"number""undefined"

Bytes per file.

maxFiles"number""undefined"

Total files (with multiple).

disabled / invalid / required"boolean""from Field"

State; also read from Field.Root.

name / form"string""undefined"

Native form submission.

className"string""undefined"

Additional classes, merged last.

FileUpload.Dropzone

The drop and click area containing the file input. children replace the default instructions (they also describe the input).

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

FileUpload.List

PropTypeDefaultDescription
removeLabel"string""Remove"

Prefix of each remove button's name.

className"string""undefined"

Additional classes, merged last.

FileUpload.Errors

Lists files rejected by the latest selection.

PropTypeDefaultDescription
className"string""undefined"

Additional classes, merged last.

formatBytes(bytes) / isFileAccepted(file, accept)

The helpers FileUpload uses, exported for your own UI.

PropTypeDefaultDescription
formatBytes"(bytes: number) => string""-"

"1.5 KB", "25 MB".

isFileAccepted"(file: File, accept?: string) => boolean""-"

Native accept matching.

Used in these blocks

Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.

Application