Zum Inhalt springen
UI Kit
Accent

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

2 Variants

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.

Variants

Ablagefläche

Dateien hineinziehen oder den Dialog per Tastatur öffnen. Der Rand läuft, solange eine Datei darüber schwebt, jeder Balken füllt sich, eine fertige Datei zeichnet ihren Haken. Erneut versuchen startet die fehlgeschlagene neu.

Kompakt

Ein Knopf und eine Zeile mit Regeln, für eine einzelne Datei in einem längeren Formular.

Details

Was Datei-Upload mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
2
Version
1.0.1 · seit 1.0.0
Datei
components/ui/file-upload.tsx
Bringt mit
utilskiticoninput
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 12 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle FileUploadProps aus components/ui/file-upload.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • labeloptional
    React.ReactNode
  • acceptoptional
    string

    Accepted types for the picker and the check, e.g. ".pdf,image/*"

  • maxSizeoptional
    number

    Max size per file in bytes

  • maxFilesoptional
    number
  • multipleoptional
    boolean
  • hintoptional
    React.ReactNode

    Line under the drop text, e.g. "PDF or PNG up to 10 MB"

  • itemsoptional
    UploadItem[]

    Controlled list, e.g. with progress from your uploader

  • defaultItemsoptional
    UploadItem[]
  • onFilesoptional
    (files: File[]) => void

    New, accepted files. Start the upload here.

  • onRemoveoptional
    (id: string) => void
  • onRetryoptional
    (id: string) => void
  • variantoptional
    'dropzone' | 'compact'

    compact: one row with a button, for tight forms

  • nameoptional
    string
  • disabledoptional
    boolean
  • erroroptional
    React.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
dropEnglisch Drop files here orDeutsch Dateien hierher ziehen oder
browseEnglisch choose from your deviceDeutsch vom Gerät auswählen
releaseEnglisch Release to uploadDeutsch Loslassen zum Hochladen
removeEnglisch RemoveDeutsch Entfernen
retryEnglisch Try againDeutsch Erneut versuchen
tooBigEnglisch is larger thanDeutsch ist größer als
wrongTypeEnglisch is not an accepted file typeDeutsch ist kein erlaubter Dateityp
tooManyEnglisch You can add up toDeutsch Du kannst höchstens
filesEnglisch filesDeutsch Dateien hinzufügen
uploadingEnglisch UploadingDeutsch Wird hochgeladen
doneEnglisch UploadedDeutsch Hochgeladen
failedEnglisch Upload failedDeutsch 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
components/ui/file-upload.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert