File Upload
A validated drag-and-drop file input with cancellable upload and real progress callbacks.
Give your AI assistant this page as context.
Connect your library with MCPChoose or drop one file. Up to 10 MB.
ui/file-uploadUsage
Get this component from a saved library export or the connected CLI. The imports below refer to local files installed in your app.
Import styles/components.css, then styles/theme.css once. Wrap your app in ThemeScope from components/ui/utils so typography, focus and overlays share the theme. The installation guide covers runtime dependencies and React/Next.js setup.
import { FileUpload } from './components/ui/file-upload';
<FileUpload label="Project attachment" onFileSelect={file=>console.info(file.name)}/>Integrating a real upload
The default component selects one file. accept and maxBytes validate the selection, including files dropped onto the control. Set name to include the file in native FormData; form connects it to an external form. required prevents empty form submission.
onFileSelect runs when a valid file is selected. onFileChange also receives null when the selection is removed, rejected or reset, allowing your application to clear its own state. An upload handler can report real progress and receives an abort signal for cancellation; no storage service is bundled.
<FileUpload
label="Project attachment"
name="attachment"
accept=".pdf,image/*"
maxBytes={5 * 1024 * 1024}
required
onFileChange={(file) => console.info(file?.name ?? "Selection cleared")}
/>Validate file types and size again on your server. The browser's accept attribute is a convenience, not a security boundary.
Design system
This component consumes your semantic colors, spacing, typography and shape tokens. Import the compiled styles and your theme once as described in Export.
Dependencies: Button, Progress.
Behavior and accessibility
- Provide meaningful labels for interactive controls.
- Connect action callbacks to your application logic.
- Verify keyboard navigation and contrast in your application.
Status: experimental during early access. Sample previews do not send emails, process payments or upload files to a service.
Source and props
This is the exported source, using the same implementation as the editor.
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { cx } from "./utils";
import { Button } from "./button";
import { Progress } from "./progress";
export interface UploadContext {
signal: AbortSignal;
onProgress: (percent: number) => void;
}
export interface FileUploadProps {
id?: string;
name?: string;
form?: string;
required?: boolean;
label?: string;
disabled?: boolean;
className?: string;
accept?: string;
maxBytes?: number;
onUpload?: (file: File, context: UploadContext) => Promise<void>;
/** Called when a valid file is selected. */
onFileSelect?: (file: File) => void;
/** Also reports removal, rejection and native form resets with null. */
onFileChange?: (file: File | null) => void;
}
function formatUploadSize(bytes: number): string {
return bytes >= 1024 * 1024
? `${Number((bytes / 1024 / 1024).toFixed(1))} MB`
: `${Math.ceil(bytes / 1024)} KB`;
}
export function validateUploadFile(
file: Pick<File, "name" | "size" | "type">,
accept: string | undefined,
maxBytes: number,
): string | null {
if (file.size > maxBytes) {
const limit = formatUploadSize(maxBytes);
return `Choose a file no larger than ${limit}.`;
}
const rules =
accept
?.split(",")
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean) ?? [];
if (
rules.length > 0 &&
!rules.some((rule) => {
const mimeType = file.type.toLowerCase();
return rule.startsWith(".")
? file.name.toLowerCase().endsWith(rule)
: rule.endsWith("/*")
? mimeType.startsWith(rule.slice(0, -1))
: mimeType === rule;
})
)
return "This file type is not supported.";
return null;
}
export function FileUpload({
id,
name,
form,
required = false,
label = "Upload a file",
accept,
maxBytes = 10 * 1024 * 1024,
onUpload,
onFileSelect,
onFileChange,
disabled = false,
className,
}: FileUploadProps) {
const generatedId = useId(),
hintId = useId(),
errorId = useId(),
controller = useRef<AbortController | null>(null),
input = useRef<HTMLInputElement>(null);
const inputId = id ?? generatedId;
const [file, setFile] = useState<File | null>(null),
[dragging, setDragging] = useState(false),
[error, setError] = useState(""),
[status, setStatus] = useState<"idle" | "uploading" | "done">("idle"),
[progress, setProgress] = useState<number | null>(null);
useEffect(() => () => controller.current?.abort(), []);
useEffect(() => {
const owner = input.current?.form;
if (!owner) return;
function reset(event: Event) {
queueMicrotask(() => {
if (event.defaultPrevented) return;
controller.current?.abort();
setFile(null);
setStatus("idle");
setProgress(null);
setError("");
setDragging(false);
onFileChange?.(null);
});
}
owner.addEventListener("reset", reset);
return () => owner.removeEventListener("reset", reset);
}, [form, onFileChange]);
function choose(next: File | undefined, fromDrop = false) {
if (!next || disabled || status === "uploading") return;
const validationError = validateUploadFile(next, accept, maxBytes);
setError(validationError ?? "");
if (validationError) {
setFile(null);
setStatus("idle");
setProgress(null);
if (input.current) input.current.value = "";
onFileChange?.(null);
return;
}
if (fromDrop && input.current) {
const transfer = new DataTransfer();
transfer.items.add(next);
input.current.files = transfer.files;
}
controller.current?.abort();
setFile(next);
setStatus("idle");
setProgress(null);
onFileSelect?.(next);
onFileChange?.(next);
}
async function upload() {
if (!file || !onUpload || disabled || status === "uploading") return;
const active = new AbortController();
controller.current = active;
setStatus("uploading");
setError("");
setProgress(null);
try {
await onUpload(file, {
signal: active.signal,
onProgress: (value) => {
if (!active.signal.aborted)
setProgress(
Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : null,
);
},
});
if (!active.signal.aborted) {
setProgress(100);
setStatus("done");
}
} catch {
if (!active.signal.aborted) {
setError("Upload failed. Please try again.");
setStatus("idle");
}
}
}
return (
<div
className={cx("cr-file-upload", className)}
aria-busy={status === "uploading" || undefined}
>
<div
className="cr-dropzone"
data-dragging={dragging || undefined}
data-disabled={disabled || status === "uploading" || undefined}
onDragOver={(event) => {
event.preventDefault();
if (!disabled && status !== "uploading") setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
if (status !== "uploading") choose(event.dataTransfer.files[0], true);
}}
>
<svg
width="24"
height="24"
className="cr-upload-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
<label htmlFor={inputId} className="cr-label">
{label}
</label>
<input
ref={input}
id={inputId}
type="file"
name={name}
form={form}
required={required}
accept={accept}
disabled={disabled || status === "uploading"}
aria-describedby={error ? `${hintId} ${errorId}` : hintId}
aria-invalid={error ? true : undefined}
onChange={(event) => choose(event.target.files?.[0])}
/>
<p id={hintId} className="cr-description">
Choose or drop one file. Up to {formatUploadSize(maxBytes)}.
</p>
</div>
{file && (
<div
className="cr-stack"
style={{ marginTop: "calc(var(--cr-space)*4)" }}
>
<p className="cr-description">
{file.name} · {Math.ceil(file.size / 1024)} KB
</p>
{status === "uploading" && (
<Progress label="Upload progress" value={progress} />
)}
<div className="cr-row">
{onUpload && status !== "done" && (
<Button
disabled={disabled}
loading={status === "uploading"}
onClick={upload}
>
Upload
</Button>
)}
<Button
variant="outline"
disabled={disabled}
onClick={() => {
controller.current?.abort();
setStatus("idle");
setFile(null);
setProgress(null);
setError("");
onFileChange?.(null);
if (input.current) {
input.current.value = "";
input.current.focus();
}
}}
>
{status === "uploading" ? "Cancel upload" : "Remove file"}
</Button>
</div>
{status === "done" && (
<p role="status" className="cr-description">
Upload complete.
</p>
)}
{!onUpload && (
<p className="cr-description">
File selected locally. Connect onUpload to send it to your storage
service.
</p>
)}
</div>
)}
{error && (
<p
id={errorId}
role="alert"
className="cr-description"
style={{ color: "var(--cr-destructive)" }}
>
{error}
</p>
)}
</div>
);
}