Datei Upload für React
Eine Ablagefläche mit Dateiliste: Fortschritt, fertig und fehlgeschlagen, mit Prüfung von Typ, Größe und Anzahl.
Eine von 6 Components in Spezielle Eingabefelder
Ablagefläche, Fortschritt und Prüfung
Ein Datei-Upload hat mehr Zustände als jedes andere Feld, leer, beim Darüberziehen, lädt, fertig, fehlgeschlagen und zu groß. Diese Component zeigt jeden davon, prüft Typ, Größe und Anzahl, bevor etwas gesendet wird, und sagt in Worten, warum eine Datei abgelehnt wurde.
Die Ablagefläche ist das große Feld für Drag and Drop mit einem Knopf zum Auswählen, einer Dateiliste mit Fortschrittsbalken, einem Neuversuch für Fehler und einem Entfernen-Knopf je Datei. „Kompakt“ passt in eine Formularzeile, etwa für einen Lebenslauf in einer Bewerbung, und behält dieselben Prüfungen in einer Zeile. Beide gehen per Tastatur und melden Fortschritt an Screenreader.
Nenne erlaubte Typen und Größengrenze, bevor jemand eine Datei wählt. Lade im Hintergrund hoch und lass Menschen mit dem Formular weitermachen. Die Component gibt dir die Dateien und nimmt den Fortschritt über Props, sie läuft also mit jedem Speicher, vom eigenen Server bis zum Cloud-Bucket.
Details
Was Datei-Upload mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Spezielle Eingabefelder
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/file-upload.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 12 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FileUploadProps aus components/ui/file-upload.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Datei-Upload selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 12 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| drop | Englisch Drop files here or | Deutsch Dateien hierher ziehen oder |
| browse | Englisch choose from your device | Deutsch vom Gerät auswählen |
| release | Englisch Release to upload | Deutsch Loslassen zum Hochladen |
| remove | Englisch Remove | Deutsch Entfernen |
| retry | Englisch Try again | Deutsch Erneut versuchen |
| tooBig | Englisch is larger than | Deutsch ist größer als |
| wrongType | Englisch is not an accepted file type | Deutsch ist kein erlaubter Dateityp |
| tooMany | Englisch You can add up to | Deutsch Du kannst höchstens |
| files | Englisch files | Deutsch Dateien hinzufügen |
| uploading | Englisch Uploading | Deutsch Wird hochgeladen |
| done | Englisch Uploaded | Deutsch Hochgeladen |
| failed | Englisch Upload failed | Deutsch Hochladen fehlgeschlagen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-describedby, aria-label, aria-live, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle progressbar.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 365 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Datei-Upload?
Mit aiw add @ai-website/file-upload. Der Befehl legt die Datei components/ui/file-upload.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und input. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Datei-Upload?
2 Varianten, Ablagefläche und Kompakt.
Kann ich die Texte von Datei-Upload übersetzen?
Ja. Datei-Upload bringt 12 eigene Texte auf Deutsch und Englisch mit, etwa „Dateien hierher ziehen oder“, „vom Gerät auswählen“ und „Loslassen zum Hochladen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Datei-Upload einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Datei-Upload mit aiw_components und installieren sie mit aiw_add @ai-website/file-upload. Nenn im Prompt die Variante, etwa variant="compact", dann schreibt der Assistent deine Inhalte direkt in die Props.