File upload for React
A dropzone with a file list: progress, done and failed, with checks for type, size and count.
One of 6 components in Special input fields
Dropzone, progress and checks
A file upload has more states than any other field, empty, dragging over, uploading, done, failed and too large. This component shows each of them, checks type, size and count before anything is sent and says in words why a file was turned away.
Dropzone is the large area for drag and drop with a button to browse, a list of files with progress bars, a retry for failed uploads and a remove button per file. Compact fits a form row, such as a CV in an application, and keeps the same checks in one line. Both work with the keyboard and announce progress to screen readers.
State the allowed types and the size limit before people pick a file. Upload in the background and let people go on with the form. The component hands you the files and reports progress through props, so it works with any storage, from your own server to a cloud bucket.
Details
What File upload brings along, read from the registry and the source.
- Category
- Special input fields
- Area
- Elements
- Variants
- 2
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/file-upload.tsx
- npm packages
- None
- Languages
- English and German, 12 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FileUploadProps interface from components/ui/file-upload.tsx, with the comments from the code.
labeloptionalReact.ReactNodeacceptoptionalstringAccepted types for the picker and the check, e.g. ".pdf,image/*"
maxSizeoptionalnumberMax size per file in bytes
maxFilesoptionalnumbermultipleoptionalbooleanhintoptionalReact.ReactNodeLine under the drop text, e.g. "PDF or PNG up to 10 MB"
itemsoptionalUploadItem[]Controlled list, e.g. with progress from your uploader
defaultItemsoptionalUploadItem[]onFilesoptional(files: File[]) => voidNew, accepted files. Start the upload here.
onRemoveoptional(id: string) => voidonRetryoptional(id: string) => voidvariantoptional'dropzone' | 'compact'compact: one row with a button, for tight forms
nameoptionalstringdisabledoptionalbooleanerroroptionalReact.ReactNode
Built-in texts
File upload writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 12 texts
| Key | English | German |
|---|---|---|
| drop | English Drop files here or | German Dateien hierher ziehen oder |
| browse | English choose from your device | German vom Gerät auswählen |
| release | English Release to upload | German Loslassen zum Hochladen |
| remove | English Remove | German Entfernen |
| retry | English Try again | German Erneut versuchen |
| tooBig | English is larger than | German ist größer als |
| wrongType | English is not an accepted file type | German ist kein erlaubter Dateityp |
| tooMany | English You can add up to | German Du kannst höchstens |
| files | English files | German Dateien hinzufügen |
| uploading | English Uploading | German Wird hochgeladen |
| done | English Uploaded | German Hochgeladen |
| failed | English Upload failed | German Hochladen fehlgeschlagen |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-label, aria-live, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
- Uses the role progressbar.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 365 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { FieldError, SuccessMark, useArrival } from '@/components/ui/input';
const TEXTS = {
en: {
drop: 'Drop files here or', browse: 'choose from your device', release: 'Release to upload', remove: 'Remove', retry: 'Try again',
tooBig: 'is larger than', wrongType: 'is not an accepted file type', tooMany: 'You can add up to', files: 'files', uploading: 'Uploading', done: 'Uploaded', failed: 'Upload failed',
},
de: {
drop: 'Dateien hierher ziehen oder', browse: 'vom Gerät auswählen', release: 'Loslassen zum Hochladen', remove: 'Entfernen', retry: 'Erneut versuchen',
tooBig: 'ist größer als', wrongType: 'ist kein erlaubter Dateityp', tooMany: 'Du kannst höchstens', files: 'Dateien hinzufügen', uploading: 'Wird hochgeladen', done: 'Hochgeladen', failed: 'Hochladen fehlgeschlagen',
},
};
type Texts = (typeof TEXTS)['en'];
export interface UploadItem {
id: string;
name: string;
/** bytes */
size: number;
/** 0 to 100 while uploading */
progress?: number;
status?: 'uploading' | 'done' | 'error';
error?: string;
/** A thumbnail URL for images */
preview?: string;
}
export function formatBytes(bytes: number, locale: 'en' | 'de') {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let n = bytes;
while (n >= 1000 && i < units.length - 1) {
n /= 1000;
i++;
}
return `${round(n, n < 10 && i > 0 ? 1 : 0).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')} ${units[i]}`;
}
const iconFor = (name: string): IconName => {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'heic'].includes(ext)) return 'image';
if (['mp4', 'mov', 'webm'].includes(ext)) return 'video';
if (['zip', 'rar', '7z'].includes(ext)) return 'archive';
if (['csv', 'xlsx', 'xls', 'numbers'].includes(ext)) return 'grid';
return 'file';
};
const STYLE = `
@keyframes kitfx-file-upload-march { to { stroke-dashoffset: -20; } }
.kitfx-file-upload-march { animation: kitfx-file-upload-march 0.7s linear infinite; }
@media (prefers-reduced-motion: reduce) { .kitfx-file-upload-march { animation: none; } }
`;
/**
* One file row: icon or thumbnail, name, size, progress, status and remove.
* New rows rise in, the bar fills, a finished upload pops its tile and draws
* a check, a removed row shrinks away before it goes.
*/
export function FileItem({ item, onRemove, onRetry, labels }: { item: UploadItem; onRemove?: () => void; onRetry?: () => void; labels?: Partial<Texts> }) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const status = item.status ?? 'done';
const progress = Math.round(item.progress ?? (status === 'done' ? 100 : 0));
const ref = useArrival<HTMLLIElement>();
const tile = useRef<HTMLSpanElement>(null);
const last = useRef(status);
useEffect(() => {
const was = last.current;
last.current = status;
const el = tile.current;
if (was === status || status === 'uploading' || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.86)', offset: 0.3 }, { transform: 'scale(1.08)', offset: 0.7 }, { transform: 'scale(1)' }], { duration: 460, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}, [status]);
const remove = () => {
const el = ref.current;
if (!onRemove) return;
if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return onRemove();
const a = el.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(0.96)', opacity: 0 }], { duration: 200, easing: 'cubic-bezier(0.4,0,1,1)', fill: 'forwards' });
a.onfinish = () => {
onRemove();
requestAnimationFrame(() => requestAnimationFrame(() => el.isConnected && a.cancel()));
};
};
return (
<li
ref={ref}
className={cn(
'relative flex items-center gap-3 overflow-hidden rounded-xl border border-border bg-card p-3 shadow-[var(--shadow-sm)]',
status === 'error' && 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]',
)}
>
{item.preview ? (
<img src={item.preview} alt="" className="size-10 shrink-0 rounded-lg border border-border object-cover" />
) : (
<span
ref={tile}
className={cn(
'grid size-10 shrink-0 place-items-center rounded-lg border border-border bg-surface text-muted-foreground',
status === 'error' && 'border-transparent bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] text-destructive',
)}
>
<Icon name={status === 'error' ? 'alert-circle' : iconFor(item.name)} size={18} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
{/* Long names shorten in the middle, the extension stays readable */}
<p className="flex min-w-0 text-sm font-medium text-foreground" title={item.name}>
<span className="truncate">{item.name.replace(/\.[a-z0-9]{1,5}$/i, '')}</span>
<span className="shrink-0">{item.name.match(/\.[a-z0-9]{1,5}$/i)?.[0]}</span>
</p>
{status === 'uploading' && <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{progress}{locale === 'de' ? ' %' : '%'}</span>}
</div>
<p className={cn('mt-0.5 text-xs', status === 'error' ? 'text-pretty leading-[1.125rem] text-destructive' : 'flex items-center gap-1.5 truncate text-muted-foreground')}>
{status === 'error' ? (
item.error ?? t.failed
) : (
<>
<span className="tabular-nums">{status === 'uploading' ? `${formatBytes((item.size * progress) / 100, locale)} / ${formatBytes(item.size, locale)}` : formatBytes(item.size, locale)}</span>
{status === 'done' && (
<>
<span aria-hidden className="text-subtle-foreground">·</span>
<span className="inline-flex items-center gap-1 text-success-foreground">
<SuccessMark size={13} className="text-current" />
{t.done}
</span>
</>
)}
</>
)}
</p>
{status === 'uploading' && (
<div
role="progressbar"
aria-label={`${t.uploading}: ${item.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="mt-2 h-1 overflow-hidden rounded-full bg-track"
>
<div className="h-full origin-left rounded-full bg-primary transition-[scale] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ scale: `${round(progress / 100, 3)} 1` }} />
</div>
)}
</div>
{status === 'error' && onRetry && (
<button
type="button"
onClick={onRetry}
aria-label={`${t.retry}: ${item.name}`}
title={t.retry}
className="group/retry relative grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-1.5 after:content-[''] hover:bg-accent hover:text-foreground active:scale-90"
>
<Icon name="refresh" size={15} className="transition-[rotate] duration-500 ease-[var(--ease-kit)] group-hover/retry:rotate-180 motion-reduce:transition-none" />
</button>
)}
{onRemove && (
<button
type="button"
onClick={remove}
aria-label={`${t.remove}: ${item.name}`}
className="relative grid size-8 shrink-0 place-items-center rounded-md text-subtle-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-1.5 after:content-[''] hover:bg-accent hover:text-foreground active:scale-90"
>
<Icon name={status === 'uploading' ? 'x' : 'trash'} size={15} />
</button>
)}
</li>
);
}
export interface FileUploadProps {
label?: React.ReactNode;
/** Accepted types for the picker and the check, e.g. ".pdf,image/*" */
accept?: string;
/** Max size per file in bytes */
maxSize?: number;
maxFiles?: number;
multiple?: boolean;
/** Line under the drop text, e.g. "PDF or PNG up to 10 MB" */
hint?: React.ReactNode;
/** Controlled list, e.g. with progress from your uploader */
items?: UploadItem[];
defaultItems?: UploadItem[];
/** New, accepted files. Start the upload here. */
onFiles?: (files: File[]) => void;
onRemove?: (id: string) => void;
onRetry?: (id: string) => void;
/** compact: one row with a button, for tight forms */
variant?: 'dropzone' | 'compact';
name?: string;
disabled?: boolean;
error?: React.ReactNode;
labels?: Partial<Texts>;
className?: string;
}
/**
* A dropzone with a file list. Keyboard: the zone is a real file input behind a label.
* Checks type, size and count and says why a file was refused.
* <FileUpload label="Attachments" accept=".pdf,image/*" maxSize={10e6} multiple onFiles={upload} />
*/
export function FileUpload({ label, accept, maxSize, maxFiles, multiple = true, hint, items: itemsProp, defaultItems, onFiles, onRemove, onRetry, variant = 'dropzone', name, disabled, error, labels, className }: FileUploadProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [inner, setInner] = useState<UploadItem[]>(defaultItems ?? []);
const items = itemsProp ?? inner;
const [over, setOver] = useState(false);
const [refused, setRefused] = useState<string[]>([]);
const accepts = (f: File) => {
if (!accept) return true;
return accept.split(',').some((a) => {
const rule = a.trim().toLowerCase();
if (rule.startsWith('.')) return f.name.toLowerCase().endsWith(rule);
if (rule.endsWith('/*')) return f.type.startsWith(rule.slice(0, -1));
return f.type === rule;
});
};
const take = (list: FileList | null) => {
if (!list) return;
const errors: string[] = [];
let files = Array.from(list).filter((f) => {
if (!accepts(f)) errors.push(`${f.name} ${t.wrongType}.`);
else if (maxSize && f.size > maxSize) errors.push(`${f.name} ${t.tooBig} ${formatBytes(maxSize, locale)}.`);
else return true;
return false;
});
if (maxFiles && items.length + files.length > maxFiles) {
errors.push(`${t.tooMany} ${maxFiles} ${t.files}.`);
files = files.slice(0, Math.max(0, maxFiles - items.length));
}
setRefused(errors);
if (!files.length) return;
if (!itemsProp) setInner((cur) => [...cur, ...files.map((f, i) => ({ id: `${f.name}-${f.size}-${Date.now()}-${i}`, name: f.name, size: f.size, status: 'done' as const }))]);
onFiles?.(files);
};
const remove = (rid: string) => {
if (!itemsProp) setInner((cur) => cur.filter((x) => x.id !== rid));
onRemove?.(rid);
};
const describedBy = cn(hint && `${id}-hint`, (error || refused.length) && `${id}-err`) || undefined;
const input = (
<input
ref={inputRef}
id={id}
type="file"
name={name}
accept={accept}
multiple={multiple}
disabled={disabled}
aria-describedby={describedBy}
onChange={(e) => {
take(e.target.files);
e.target.value = '';
}}
className="peer sr-only"
/>
);
return (
<div className={cn('grid w-full gap-3', className)}>
{label && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
)}
{variant === 'compact' ? (
<div className="flex items-center gap-3">
{input}
<label
htmlFor={id}
className="group/pick inline-flex h-11 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border-strong bg-background px-4 text-sm font-medium text-foreground shadow-[var(--shadow-sm)] transition-[background-color,box-shadow,scale] duration-200 hover:bg-accent active:scale-[0.97] active:duration-100 peer-focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_22%,transparent)] peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
>
<Icon name="upload" size={16} className="transition-[translate] duration-300 ease-[var(--ease-kit)] group-hover/pick:-translate-y-0.5 motion-reduce:transition-none" />
{t.browse.charAt(0).toUpperCase() + t.browse.slice(1)}
</label>
{hint && (
<p id={`${id}-hint`} className="min-w-0 text-[13px] text-muted-foreground">
{hint}
</p>
)}
</div>
) : (
<div
onDragEnter={(e) => {
e.preventDefault();
if (!disabled) setOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setOver(false);
if (!disabled) take(e.dataTransfer.files);
}}
>
{input}
<label
htmlFor={id}
data-over={over || undefined}
className={cn(
'group/drop relative flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border-strong bg-surface px-6 py-9 text-center transition-[border-color,background-color,box-shadow] duration-200',
'hover:border-[color-mix(in_oklab,var(--foreground)_30%,var(--border-strong))] hover:bg-[color-mix(in_oklab,var(--foreground)_2%,var(--surface))]',
'peer-focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] peer-focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]',
'data-over:border-transparent data-over:bg-[color-mix(in_oklab,var(--primary)_6%,var(--surface))]',
(error || refused.length > 0) && 'border-[color-mix(in_oklab,var(--destructive)_55%,var(--border-strong))]',
disabled && 'cursor-not-allowed opacity-55',
)}
>
{/* While a file hovers over the zone its edge runs like a marching line */}
{over && (
<svg aria-hidden className="pointer-events-none absolute -inset-px size-[calc(100%+2px)] overflow-visible text-primary">
<style dangerouslySetInnerHTML={{ __html: STYLE }} />
<rect x="0.75" y="0.75" rx="12" style={{ width: "calc(100% - 1.5px)", height: "calc(100% - 1.5px)" }} fill="none" stroke="currentColor" strokeWidth="1.5" strokeDasharray="6 4" className="kitfx-file-upload-march" />
</svg>
)}
<span className="grid size-11 place-items-center rounded-xl border border-border bg-card text-muted-foreground shadow-[var(--highlight),var(--shadow-sm)] transition-[translate,scale,color] duration-300 ease-[var(--ease-kit)] group-hover/drop:-translate-y-0.5 group-data-over/drop:-translate-y-1.5 group-data-over/drop:scale-110 group-data-over/drop:text-primary">
<Icon name="cloud-upload" size={20} className="group-data-over/drop:animate-[kit-float_1.4s_ease-in-out_infinite] motion-reduce:animate-none" />
</span>
<span className="text-sm text-muted-foreground">
{over ? (
<span className="font-medium text-foreground">{t.release}</span>
) : (
<>
{t.drop} <span className="font-medium text-foreground underline decoration-border-strong underline-offset-4 group-hover/drop:decoration-foreground">{t.browse}</span>
</>
)}
</span>
{hint && (
<span id={`${id}-hint`} className="text-xs text-subtle-foreground">
{hint}
</span>
)}
</label>
</div>
)}
{(error || refused.length > 0) && (
<div id={`${id}-err`} aria-live="polite" className="grid gap-1">
{error && <FieldError>{error}</FieldError>}
{refused.map((r) => (
<FieldError key={r}>{r}</FieldError>
))}
</div>
)}
{items.length > 0 && (
<ul className="grid gap-2">
{items.map((it) => (
<FileItem key={it.id} item={it} labels={labels} onRemove={() => remove(it.id)} onRetry={onRetry ? () => onRetry(it.id) : undefined} />
))}
</ul>
)}
</div>
);
}Frequent questions
How do I install File upload?
With aiw add @ai-website/file-upload. The command puts components/ui/file-upload.tsx into your project, the code is yours from then on. It brings along icon and input. It needs no further npm packages.
Which variants does File upload have?
2 variants, Dropzone and Compact.
Can I translate the texts of File upload?
Yes. File upload carries 12 texts of its own in English and German, such as “Drop files here or”, “choose from your device” and “Release to upload”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with File upload?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find File upload with aiw_components and install it with aiw_add @ai-website/file-upload. Name the variant in your prompt, for example variant="compact", and the assistant writes your content straight into the props.