DEIN TÄGLICHES UI/UX-COACHING / LEKTION 14

Loading States: Wenn die Daten fehlen, aber die UX funktionieren muss

Ein Interface muss auch funktionieren, wenn noch nicht alles da ist. Heute gestaltest du die Zeit zwischen einer Anfrage und ihrem Ergebnis: verständlich, bedienbar und mit klarer Aussage darüber, welche Daten gerade sichtbar sind.

ca. 15 Minuten inklusive Übung

Dein Lernziel

Du unterscheidest Erstladen, Aktualisieren, leere Ergebnisse und Fehler. Du erhältst nutzbare Daten, erkennst veraltete Antworten und gestaltest eine zugängliche Rückmeldung ohne unnötige Layoutsprünge.

01 / DAS STATE-LABOR · 3 MINUTEN

Was bleibt sichtbar, während du wartest?

In einer fiktiven Buch-App möchtest du Romantasy entdecken. Beim ersten Besuch gibt es noch keine Ergebnisse. Später wechselst du auf Contemporary. Die neue Anfrage benötigt zwei Sekunden – oder schlägt fehl. In beiden Situationen wäre ein unterschiedsloses „Lädt …“ zu wenig.

INTERAKTIVE UI-SIMULATION

Die sechs Schalter zeigen Standbilder. Für einen zeitlichen Ablauf nutze „Neu laden“ oder ändere den Filter. Alle Bücher und Antworten sind fiktiv; es gibt keine Netzwerkabfrage.

Bücher entdecken

Eine fiktive Buchauswahl

2 Bücher für Romantasy geladen.

Angefragt: RomantasyAngezeigt: Romantasy

Angezeigte Buchergebnisse

  • Die Sternenerbin

    M. Beispiel

    Romantasy

  • Der letzte Schwur

    L. Beispiel

    Romantasy

Beim Filterwechsel wird der vorherige Timer beendet. Zusätzlich nimmt das Zustandsmodell nur Antworten der aktuellen Anfrage an. Die Verzögerung gilt ab dem nächsten Start. Nach 3 Sekunden erscheint einmalig ein Hinweis – ein gewählter Beispielwert, keine allgemeine UX-Regel.

·

Geöffnet aus dem Datenstand: . Dies ist ein fiktives Buchdetail ohne Kauf- oder Speicherfunktion. Der Inhalt dieses Details bleibt auch bei einer inzwischen aktualisierten Ergebnisliste dem ausgewählten Buch zugeordnet.

Erstladen und Aktualisieren sind verschiedene Situationen.

Beim Erstladen kann ein Skeleton zeigen, welche Struktur zu erwarten ist. Beim Aktualisieren sind dagegen bereits Inhalte vorhanden. Sie sofort zu entfernen, erzeugt möglicherweise Unruhe und verhindert das Weiterarbeiten. Für die Buchsuche hält dieser eigene Entwurf den bisherigen Datenstand deshalb sichtbar.

Das ähnelt einem Stale-While-Revalidate-Verhalten: vorhandene Daten anzeigen und parallel eine neue Antwort anfordern. Das Labor implementiert aber keinen vollständigen Cache und keine HTTP-Caching-Strategie. Entscheidend ist hier der sichtbare Filterbezug: Romantasy-Ergebnisse werden beim Wechsel auf Contemporary nicht plötzlich als passende neue Treffer ausgegeben.

Teste auch ein Buch während eines laufenden Refreshs. Das Detail gehört weiterhin zum konkret ausgewählten Buch. Es wird nicht unbemerkt durch ein anderes ersetzt, nur weil im Hintergrund die Liste aktualisiert wurde.

02 / WANN WELCHES PATTERN? · 2 MINUTEN

Der Zustand erklärt, welche Information verlässlich ist.

  • Erstladen: Noch keine Daten. Zeige Struktur oder einen dezenten Indikator und benenne, was geladen wird. Ein leerer Bereich darf nicht wie ein abgeschlossenes Suchergebnis wirken.
  • Hintergrundaktualisierung: Bisherige Daten dürfen sichtbar bleiben, sofern das für die Aufgabe vertretbar ist. Kennzeichne ihren Stand und den angefragten Filter. Bei sicherheitskritischen oder zeitabhängigen Entscheidungen kann das Weiterverwenden alter Daten ungeeignet sein.
  • Leeres Ergebnis: Die Anfrage war erfolgreich, aber es gibt keine Treffer. Erkläre die Einschränkung und biete einen sinnvollen nächsten Schritt an, etwa das Entfernen des Genre-Filters.
  • Fehler: Die Antwort konnte nicht geladen werden. Das ist kein Beleg für „keine Treffer“. Erhalte vorhandene Inhalte und ermögliche einen gezielten neuen Versuch.
  • Teilerfolg: Ein Teil ist vorhanden, ein anderer fehlt. Zeige die verfügbaren Inhalte mit einem Hinweis auf die Unvollständigkeit. Ein einzelnes Buch ist nicht automatisch die vollständige Ergebnisliste.

Skeletons reservieren Platz – sie garantieren keine Stabilität.

Platzhalter sollten Form und Platzbedarf des späteren Inhalts möglichst realistisch abbilden. Für nachträglich geladene Inhalte kann reservierter Raum Layoutverschiebungen reduzieren. web.dev: Optimize Cumulative Layout Shift.

Die Musterlösung verwendet dieselben Kartenraster und Cover-Proportionen für Skeletons und Bücher, zusätzlich eine Mindesthöhe für die Ergebnisfläche. Das ist eine Entwurfsentscheidung, keine Zusicherung eines CLS-Werts von null. Lange Titel, vier statt zwei Bücher und vergrößerte Schrift brauchen weiterhin Platz. Verstecke Inhalte nicht hinter einer festen Höhe, nur um die Außenmaße zu bewahren.

03 / DEINE CHALLENGE · 8 MINUTEN

Ein robuster Entdecken-Screen bei 375 px.

Die angenommene API antwortet normalerweise nach 400 ms, gelegentlich erst nach vier Sekunden. Diese Zeiten sind Übungsannahmen, keine gemessenen Produktdaten.

  1. Zustandsarchitektur · 2 Minuten: Definiere Erstladen, Refresh, Leer und Fehler. Notiere für jeden Zustand: sichtbarer Filter, sichtbare Daten, Statusmeldung und mögliche Aktion.
  2. UI · 4 Minuten: Gestalte zwei mobile Ansichten: Erstladen mit Skeletons und fehlgeschlagener Refresh mit erhaltenen Ergebnissen. Erkläre im zweiten Entwurf, zu welchem Filter diese Ergebnisse gehören.
  3. Belastungstest · 2 Minuten: Was passiert bei einem weiteren Filterwechsel? Bei einer verspäteten Antwort? Beim Öffnen eines alten Ergebnisses? Beschreibe außerdem einen verständlichen Ausweg nach einem Fehler.

Dein Qualitätskriterium: keine unnötigen Layoutsprünge, keine irreführende Zuordnung und keine Sackgasse. Ein Wireframe mit Zustandsnotizen reicht für den ersten Durchgang. Ein technisch belastbarer Prototyp braucht zusätzliche Zeit.

04 / KOMMENTIERTE MUSTERLÖSUNG

Datenstand und aktuelle Anfrage getrennt halten.

Zustandsmodell und Designentscheidungen öffnen

Das Labor oben ist zugleich die bedienbare Musterlösung. Es ist ein eigener Gestaltungsvorschlag mit fiktiven Daten, keine getestete Buch-App. Die Schalter zeigen Standbilder; „Neu laden“ und Filterwechsel starten zeitliche Simulationen.

1. Ein Snapshot gehört zu seinem Filter.

Der aktuelle Wunsch steht in selected. Die sichtbaren Bücher liegen zusammen mit ihrem Filter in snapshot. Erst eine gültige Antwort ersetzt diesen Snapshot. So bleibt bei einem Fehler sichtbar: angefragt ist Contemporary, angezeigt wird noch Romantasy. Ein Fehler vor dem ersten Erfolg zeigt dagegen keine erfundenen alten Daten.

Auch ein erfolgreiches Ergebnis mit null Büchern ist ein Snapshot. Eine spätere Aktualisierung darf daraus nicht „noch nie geladen“ machen. Umgekehrt sollte ein fehlgeschlagener Refresh keine leere Antwort vortäuschen.

2. Nur die aktuelle Anfrage darf die Ansicht ändern.

Jeder Start erhält eine neue Kennung. Beim nächsten Filterwechsel beendet das Labor die alten Timer. Zusätzlich akzeptiert der Reducer nur Meldungen mit der aktuellen Kennung. Das gilt für Erfolg, Fehler und den Hinweis auf längere Wartezeit. Abbruch setzt die laufende Anfrage zurück; eine verspätete Antwort kann danach keine Bücher mehr übernehmen.

Bei einer echten Fetch-Anfrage würde ich den Transport mit AbortController abbrechen und weiterhin prüfen, ob die Antwort noch zur aktuellen Anfrage gehört. Ein Abbruch beim Client bedeutet nicht zwangsläufig, dass ein Server seine Verarbeitung beendet. Die React-Dokumentation zeigt ebenfalls eine Absicherung gegen Antworten in falscher Reihenfolge. React: Fetching data with Effects.

3. Warten bekommt einen Ausweg.

Nach drei Sekunden erscheint im Labor ein einmaliger Hinweis. Die Schwelle ist ein gewählter Beispielwert, kein universelles Gesetz. Bei acht Sekunden bleiben Filterwechsel und Abbruch möglich. Es gibt keinen erfundenen Prozentfortschritt und keinen sekündlich vorgelesenen Countdown.

„Anfrage abbrechen“ beendet nur den simulierten Ladevorgang. Ein neuer Filterwunsch bleibt als solcher sichtbar, bis du ihn änderst oder neu lädst. „Erneut versuchen“ simuliert bewusst eine erfolgreiche Antwort; damit lässt sich die Erholung vom Fehler prüfen. Es ist keine Zusage, dass ein echter Retry immer gelingt.

4. Bereits sichtbare Inhalte bleiben bedienbar.

Ein Buch lässt sich während des Refreshs öffnen. Das Dialogdetail bewahrt das ausgewählte Objekt samt ursprünglichem Filterbezug. Neue Ergebnisse ändern die Liste dahinter, nicht das bereits betrachtete Buch. Beim Schließen kehrt der Fokus zur Buchaktion zurück; wurde sie inzwischen entfernt, zur stabilen Aktion „Neu laden“.

5. Teilerfolg wird nicht als vollständiger Erfolg verkauft.

Die Teilantwort enthält ein Buch und einen gesonderten Hinweis. Ein erneuter Versuch kann die vollständige Liste anfordern. In einem echten System müsste die API klar unterscheiden, ob etwa nur Bilder fehlen oder tatsächlich Ergebnisse unvollständig sind. Unterschiedliche Ausfälle brauchen unterschiedliche Meldungen.

Vollständigen React-Code, Zustandsmodell und CSS ansehen

Dies ist der tatsächliche Code der Simulation. Für eine Übernahme sind die Projekt-Importpfade anzupassen. Das Modell trennt Ergebnis-Snapshot, aktuelle Anfrage, Fehler und Abbruch. Die Timer ersetzen ausschließlich für diese Übung die Netzwerkantwort.

coaching-loading.ts

export type DiscoverFilter = "romantasy" | "contemporary" | "all";
export type LoadOutcome = "success" | "empty" | "error" | "partial";
export type LoadPreset = "initial" | "refresh" | "empty" | "error" | "success" | "partial";
export type DemoBook = { id: string; title: string; author: string; genre: string };
export const discoverFilters: Record<DiscoverFilter, string> = {
  romantasy: "Romantasy", contemporary: "Contemporary", all: "Alle Bücher",
};
const fiction: Record<Exclude<DiscoverFilter, "all">, DemoBook[]> = {
  romantasy: [
    { id: "stern", title: "Die Sternenerbin", author: "M. Beispiel", genre: "Romantasy" },
    { id: "schwur", title: "Der letzte Schwur", author: "L. Beispiel", genre: "Romantasy" },
  ],
  contemporary: [
    { id: "sommer", title: "Ein Sommer am Fluss", author: "N. Beispiel", genre: "Contemporary" },
    { id: "morgen", title: "Vielleicht morgen", author: "T. Beispiel", genre: "Contemporary" },
  ],
};
export function discoverBooks(filter: DiscoverFilter): DemoBook[] {
  return filter === "all" ? [...fiction.romantasy, ...fiction.contemporary] : fiction[filter];
}
export type DiscoverSnapshot = { filter: DiscoverFilter; books: DemoBook[]; partial: boolean };
export type DiscoverState = {
  selected: DiscoverFilter;
  snapshot: DiscoverSnapshot | null;
  pending: number | null;
  error: boolean;
  slow: boolean;
  cancelled: boolean;
};
export type DiscoverAction =
  | { type: "start"; id: number; filter: DiscoverFilter }
  | { type: "resolve"; id: number; outcome: LoadOutcome }
  | { type: "slow"; id: number }
  | { type: "cancel"; id: number }
  | { type: "preset"; state: DiscoverState };
export function discoverPreset(preset: LoadPreset, id: number): DiscoverState {
  const state: DiscoverState = {
    selected: "romantasy",
    snapshot: { filter: "romantasy", books: discoverBooks("romantasy"), partial: false },
    pending: null, error: false, slow: false, cancelled: false,
  };
  if (preset === "initial") return { ...state, snapshot: null, pending: id };
  if (preset === "refresh") return { ...state, selected: "contemporary", pending: id };
  if (preset === "empty") return { ...state, snapshot: { filter: "romantasy", books: [], partial: false } };
  if (preset === "error") return { ...state, selected: "contemporary", error: true };
  if (preset === "partial") return { ...state, snapshot: { filter: "romantasy", books: discoverBooks("romantasy").slice(0, 1), partial: true } };
  return state;
}
export function discoverReducer(state: DiscoverState, action: DiscoverAction): DiscoverState {
  if (action.type === "preset") return action.state;
  if (action.type === "start") return { ...state, selected: action.filter, pending: action.id, error: false, slow: false, cancelled: false };
  // Late responses and timers from superseded or cancelled requests cannot commit.
  if (state.pending === null || state.pending !== action.id) return state;
  if (action.type === "slow") return { ...state, slow: true };
  if (action.type === "cancel") return { ...state, pending: null, slow: false, cancelled: true };
  if (action.outcome === "error") return { ...state, pending: null, slow: false, error: true };
  const books = action.outcome === "empty" ? [] : discoverBooks(state.selected);
  return {
    ...state, pending: null, error: false, slow: false, cancelled: false,
    snapshot: { filter: state.selected, books: action.outcome === "partial" ? books.slice(0, 1) : books, partial: action.outcome === "partial" },
  };
}
export function discoverStatus(state: DiscoverState): string {
  const filter = discoverFilters[state.selected];
  if (state.pending !== null) return state.slow
    ? `Die Anfrage für ${filter} dauert länger als erwartet. Du kannst weiter warten, Filter ändern oder abbrechen.`
    : `${state.snapshot ? "Ergebnisse werden aktualisiert" : "Bücher werden geladen"}: ${filter}.`;
  if (state.error) return `Laden für ${filter} fehlgeschlagen. ${state.snapshot ? "Bisherige Ergebnisse bleiben sichtbar." : "Noch keine Ergebnisse verfügbar."}`;
  if (state.cancelled) return `Anfrage für ${filter} abgebrochen. ${state.snapshot ? "Bisherige Ergebnisse bleiben sichtbar." : "Noch keine Ergebnisse verfügbar."}`;
  if (state.snapshot?.partial) return `Teilergebnis für ${filter}: ${state.snapshot.books.length} Buch verfügbar. Weitere Ergebnisse konnten nicht geladen werden.`;
  return state.snapshot?.books.length ? `${state.snapshot.books.length} Bücher für ${filter} geladen.` : `Keine Bücher für ${filter} gefunden.`;
}

coaching-loading-lab.tsx

"use client";

import { useEffect, useId, useReducer, useRef, useState } from "react";
import {
  discoverFilters, discoverPreset, discoverReducer, discoverStatus,
  type DemoBook, type DiscoverFilter, type LoadOutcome, type LoadPreset,
} from "@/lib/coaching-loading";
import "./coaching-loading-lab.css";

const presets: [LoadPreset, string][] = [
  ["initial", "Erstladen"], ["refresh", "Aktualisieren"], ["empty", "Leer"],
  ["error", "Fehler"], ["success", "Erfolg"], ["partial", "Teilerfolg"],
];

export function CoachingLoadingLab() {
  const id = useId();
  const [state, dispatch] = useReducer(discoverReducer, discoverPreset("success", 0));
  const [preset, setPreset] = useState<LoadPreset | null>("success");
  const [delay, setDelay] = useState(2000);
  const [outcome, setOutcome] = useState<LoadOutcome>("success");
  const [openedBook, setOpenedBook] = useState<{ book: DemoBook; filter: string } | null>(null);
  const sequence = useRef(0);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const reload = useRef<HTMLButtonElement>(null);
  const dialog = useRef<HTMLDialogElement>(null);
  const dialogTitle = useRef<HTMLHeadingElement>(null);
  const bookTrigger = useRef<HTMLButtonElement | null>(null);

  function clearTimers() {
    timers.current.forEach(clearTimeout);
    timers.current = [];
  }
  useEffect(() => () => timers.current.forEach(clearTimeout), []);
  useEffect(() => {
    if (openedBook) {
      dialog.current?.showModal();
      dialogTitle.current?.focus();
    }
  }, [openedBook]);

  function showPreset(next: LoadPreset) {
    clearTimers();
    setPreset(next);
    dispatch({ type: "preset", state: discoverPreset(next, ++sequence.current) });
  }
  function begin(filter = state.selected, nextOutcome = outcome) {
    clearTimers();
    setPreset(null);
    const requestId = ++sequence.current;
    dispatch({ type: "start", id: requestId, filter });
    timers.current = [
      setTimeout(() => dispatch({ type: "slow", id: requestId }), 3000),
      setTimeout(() => {
        if (sequence.current !== requestId) return;
        clearTimers();
        dispatch({ type: "resolve", id: requestId, outcome: nextOutcome });
      }, delay),
    ];
  }
  function retry() {
    // This stable control remains mounted when the error panel disappears.
    reload.current?.focus();
    begin(state.selected, "success");
  }
  function cancel() {
    clearTimers();
    if (state.pending !== null) dispatch({ type: "cancel", id: state.pending });
    reload.current?.focus();
    setPreset(null);
  }
  const stale = state.snapshot !== null && state.snapshot.filter !== state.selected;
  const pending = state.pending !== null;

  return <div className="loading-lab">
    <p className="eyebrow">INTERAKTIVE UI-SIMULATION</p>
    <div className="loading-lab-presets" role="group" aria-label="Zustandsbeispiele">
      {presets.map(([value, label]) => <button key={value} type="button" aria-pressed={preset === value} onClick={() => showPreset(value)}>{label}</button>)}
    </div>
    <p className="loading-lab-note">Die sechs Schalter zeigen Standbilder. Für einen zeitlichen Ablauf nutze „Neu laden“ oder ändere den Filter. Alle Bücher und Antworten sind fiktiv; es gibt keine Netzwerkabfrage.</p>
    <div className="loading-lab-settings">
      <label htmlFor={`${id}-delay`}>Simulierte Antwortzeit<select id={`${id}-delay`} value={delay} onChange={event => setDelay(Number(event.target.value))}><option value="400">400 ms</option><option value="2000">2 Sekunden</option><option value="4000">4 Sekunden</option><option value="8000">8 Sekunden</option></select></label>
      <label htmlFor={`${id}-outcome`}>Nächste Antwort<select id={`${id}-outcome`} value={outcome} onChange={event => setOutcome(event.target.value as LoadOutcome)}><option value="success">Erfolg</option><option value="empty">Keine Treffer</option><option value="error">Fehler</option><option value="partial">Teilerfolg</option></select></label>
    </div>
    <div className="loading-lab-screen">
      <header><h3 id={`${id}-title`}>Bücher entdecken</h3><p>Eine fiktive Buchauswahl</p></header>
      <label className="loading-lab-filter" htmlFor={`${id}-filter`}>Gewünschter Filter<select id={`${id}-filter`} value={state.selected} onChange={event => begin(event.target.value as DiscoverFilter)}>{Object.entries(discoverFilters).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
      <div className="loading-lab-actions"><button ref={reload} type="button" onClick={() => begin()}>Neu laden</button><button type="button" onClick={cancel} disabled={!pending}>Anfrage abbrechen</button></div>
      <p className="loading-lab-status" role="status" aria-atomic="true">{discoverStatus(state)}</p>
      <div className="loading-lab-result-context"><strong>Angefragt: {discoverFilters[state.selected]}</strong><span>{state.snapshot ? `Angezeigt: ${discoverFilters[state.snapshot.filter]}${stale || pending || state.error || state.cancelled ? " · bisheriger Datenstand" : ""}` : "Angezeigt: noch kein Datenstand"}</span></div>
      {stale && <p className="loading-lab-warning">Die sichtbaren Bücher gehören noch zum vorherigen Filter. Sie sind keine Treffer für {discoverFilters[state.selected]}.</p>}
      <section aria-labelledby={`${id}-results-title`} aria-busy={pending} className="loading-lab-results">
        <h4 className="sr-only" id={`${id}-results-title`}>Angezeigte Buchergebnisse</h4>
        {state.snapshot === null ? pending ? <div className="loading-lab-books" aria-hidden="true">{[0, 1].map(index => <div className="loading-lab-book loading-lab-skeleton" key={index}><div className="loading-lab-cover" /><div><span /><span /><span /></div></div>)}</div> : <p>Noch keine Bücher geladen. Starte eine neue Anfrage.</p> : state.snapshot.books.length > 0 ? <ul className="loading-lab-books">{state.snapshot.books.map(book => <li className="loading-lab-book" key={book.id}><div className="loading-lab-cover" aria-hidden="true">{book.title.slice(0, 1)}</div><div><h4>{book.title}</h4><p>{book.author}</p><p>{book.genre}</p><button type="button" onClick={event => { bookTrigger.current = event.currentTarget; setOpenedBook({ book, filter: discoverFilters[state.snapshot!.filter] }); }}>Buch ansehen<span className="sr-only">: {book.title}</span></button></div></li>)}</ul> : <div className="loading-lab-empty"><h4>Keine passenden Bücher</h4><p>Für diese simulierte Anfrage gibt es keine Treffer. Probiere die Auswahl ohne Genre-Einschränkung.</p><button type="button" onClick={() => { reload.current?.focus(); begin("all", "success"); }}>Alle Bücher anzeigen</button></div>}
      </section>
      {(state.error || state.snapshot?.partial) && <div className="loading-lab-recovery"><p>{state.error ? "Die Anfrage ist fehlgeschlagen. Das bedeutet nicht, dass es keine passenden Bücher gibt." : "Die Liste ist unvollständig. Fehlende Bücher sind kein Beleg für fehlende Angebote."}</p><button type="button" onClick={retry}>{state.error ? "Erneut versuchen" : "Vollständige Liste erneut laden"}</button><p>Dieser Retry simuliert eine erfolgreiche Antwort mit der eingestellten Dauer.</p></div>}
    </div>
    <p className="loading-lab-note">Beim Filterwechsel wird der vorherige Timer beendet. Zusätzlich nimmt das Zustandsmodell nur Antworten der aktuellen Anfrage an. Die Verzögerung gilt ab dem nächsten Start. Nach 3 Sekunden erscheint einmalig ein Hinweis – ein gewählter Beispielwert, keine allgemeine UX-Regel.</p>
    <dialog ref={dialog} className="loading-lab-dialog" aria-labelledby={`${id}-book-title`} onClose={() => { setOpenedBook(null); const target = bookTrigger.current; if (target?.isConnected) target.focus(); else reload.current?.focus(); }}>
      <h3 ref={dialogTitle} tabIndex={-1} id={`${id}-book-title`}>{openedBook?.book.title}</h3><p>{openedBook?.book.author} · {openedBook?.book.genre}</p><p>Geöffnet aus dem Datenstand: {openedBook?.filter}. Dies ist ein fiktives Buchdetail ohne Kauf- oder Speicherfunktion. Der Inhalt dieses Details bleibt auch bei einer inzwischen aktualisierten Ergebnisliste dem ausgewählten Buch zugeordnet.</p><button type="button" onClick={() => dialog.current?.close()}>Buchdetail schließen</button>
    </dialog>
  </div>;
}

coaching-loading-lab.css

.loading-lab { margin: 28px 0; padding: 24px; background: #f0f2f5; border: 1px solid #d6dce4; border-radius: 16px; min-width: 0; }
.loading-lab button, .loading-lab select { font: inherit; font-size: 14px; min-height: 44px; border: 1px solid #b7c0ce; border-radius: 8px; color: #182332; background: #fff; padding: 10px 13px; max-width: 100%; overflow-wrap: anywhere; }
.loading-lab button { cursor: pointer; }
.loading-lab button[aria-pressed="true"] { background: #1749de; border-color: #1749de; color: white; }
.loading-lab button:disabled { cursor: default; background: #f0f2f5; color: #657184; }
.loading-lab :is(button,select,summary):focus-visible, .loading-lab-dialog :is(button,h3):focus-visible { outline: 3px solid #1749de; outline-offset: 3px; }
.loading-lab-presets, .loading-lab-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.loading-lab .loading-lab-note { font-size: 13px; line-height: 1.75; color: #465366; }
.loading-lab-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
.loading-lab label { display: grid; gap: 7px; font-size: 14px; font-weight: 600; min-width: 0; }
.loading-lab select { width: 100%; }
.loading-lab-screen { background: #fff; padding: 24px; border: 1px solid #d6dce4; border-radius: 12px; min-width: 0; }
.loading-lab-screen h3 { font-size: 26px; line-height: 1.2; margin: 0; }
.loading-lab-screen header p { font-size: 14px; margin: 8px 0 24px; color: #526075; }
.loading-lab-filter { max-width: 360px; margin-bottom: 16px; }
.loading-lab .loading-lab-status { margin: 20px 0 12px; padding: 14px; border-radius: 8px; background: #eef2ff; font-size: 14px; line-height: 1.7; min-height: 76px; }
.loading-lab-result-context { display: grid; gap: 6px; font-size: 13px; line-height: 1.7; margin-bottom: 18px; }
.loading-lab-result-context span { color: #526075; }
.loading-lab .loading-lab-warning { font-size: 14px; background: #fff4de; border-left: 3px solid #996016; padding: 12px; color: #654413; }
.loading-lab-results { min-height: 330px; }
.loading-lab .loading-lab-books { display: grid; gap: 14px; padding: 0; margin: 0; list-style: none; }
.loading-lab .loading-lab-book { display: grid; grid-template-columns: 70px minmax(0,1fr); align-items: start; gap: 18px; padding: 14px; min-height: 158px; border: 1px solid #e0e4ea; border-radius: 10px; }
.loading-lab-cover { width: 70px; aspect-ratio: 2/3; background: #e8edfb; color: #3154a2; border-radius: 5px; display: grid; place-items: center; font: 32px var(--font-serif),serif; }
.loading-lab-book h4 { font-size: 17px; line-height: 1.45; margin: 0 0 5px; overflow-wrap: anywhere; }
.loading-lab .loading-lab-book p { font-size: 13px; line-height: 1.6; margin: 3px 0; color: #526075; }
.loading-lab-book button { margin-top: 8px; }
.loading-lab-skeleton { height: 158px; }
.loading-lab-skeleton .loading-lab-cover, .loading-lab-skeleton span { background: #e5e9ef; }
.loading-lab-skeleton span { display: block; height: 15px; margin: 6px 0 13px; border-radius: 3px; }
.loading-lab-skeleton span:nth-child(2) { width: 65%; }
.loading-lab-skeleton span:nth-child(3) { width: 80%; height: 36px; margin-top: 24px; }
.loading-lab-empty { padding: 18px; border: 1px dashed #b7c0ce; border-radius: 10px; }
.loading-lab-empty h4 { margin: 0 0 10px; font-size: 20px; }
.loading-lab .loading-lab-empty p, .loading-lab .loading-lab-recovery p { font-size: 14px; line-height: 1.75; }
.loading-lab-recovery { border-top: 1px solid #e0e4ea; margin-top: 20px; padding-top: 8px; }
.loading-lab-dialog { width: 520px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 32px); margin: auto; padding: 24px; border: 1px solid #b7c0ce; border-radius: 14px; background: white; color: #182332; overflow: auto; }
.loading-lab-dialog::backdrop { background: rgb(12 20 35 / 55%); }
.loading-lab-dialog h3 { font-size: 26px; line-height: 1.3; margin: 0 0 14px; }
.loading-lab-dialog p { font-size: 15px; line-height: 1.8; margin: 12px 0; }
.loading-lab-dialog button { min-height: 44px; font: inherit; padding: 10px 14px; border: 1px solid #b7c0ce; border-radius: 8px; background: #fff; color: #182332; }
@media(max-width:600px) { .loading-lab { padding: 16px; } .loading-lab-settings { grid-template-columns: 1fr; } .loading-lab-screen { padding: 16px; } .loading-lab .loading-lab-book { grid-template-columns: 44px minmax(0,1fr); gap: 10px; padding: 12px; min-height: 178px; } .loading-lab-cover { width: 44px; font-size: 25px; } .loading-lab-skeleton { height: 178px; } .loading-lab-results { min-height: 370px; } .loading-lab-screen h3 { font-size: 23px; } }

05 / ACCESSIBILITY

Status mitteilen, Fokus erhalten.

Die Ergebnisfläche trägt aria-busy, während sie aktualisiert wird. Das informiert assistive Technologien über die laufende Änderung. Die Statusmeldung steht bewusst außerhalb dieses Bereichs, damit ihre Aktualisierung nicht durch dessen Busy-Zustand verzögert wird. MDN: aria-busy.

Für die kurzen Meldungen verwendet die Musterlösung role="status". Diese Rolle ist implizit eine höfliche Live-Region; der Fokus wandert nicht auf jede neue Meldung. Die dekorativen Skeletons sind mit aria-hidden ausgeblendet und nicht fokussierbar. MDN: status role.

Prüfe mit Tastatur und Screenreader: Wird der Beginn verständlich, ohne dich ständig zu unterbrechen? Bleibt der Fokus beim Filterwechsel erhalten? Sind Fehler und Retry erreichbar? Teste bei 375 und 320 CSS-Pixeln Breite sowie mit vergrößerter Schrift. Die statischen Skeletons benötigen keine Bewegung; auch eine Animation wäre kein Ersatz für eine verständliche Meldung.

06 / REFLEXION · 2 MINUTEN

Welche Antwort darf den Screen verändern?

Die Nutzerin ändert den Filter während einer Anfrage.

Brichst du ab, lässt du beide Anfragen weiterlaufen oder speicherst du Ergebnisse zwischen? Welche Risiken entstehen dabei?

Mögliche Antwort zu Filterwechsel und Cache

Für diesen einfachen Entdecken-Screen beende ich die alte Anfrage möglichst und akzeptiere nur die Antwort der aktuellen Kennung. Das verhindert, dass eine langsame alte Antwort eine neuere Auswahl überschreibt. Vorhandene Bücher behalten ihren alten Filterbezug.

Ein Cache kann zusätzlich sinnvoll sein, wenn Daten wiederverwendbar sind. Dann braucht jede Antwort einen passenden Schlüssel aus allen relevanten Suchparametern und eine Regel zur Gültigkeit. Parallel laufende Anfragen sind nicht grundsätzlich falsch; sie dürfen nur nicht beliebig dieselbe sichtbare Liste überschreiben. Einen vollständigen Cache implementiert diese Übung nicht.

Das Skeleton bleibt acht Sekunden sichtbar.

Wann und wie gibst du zusätzliche Rückmeldung?

Mögliche Antwort zu langer Wartezeit

Ich orientiere die Schwelle an erwarteten Antwortzeiten, Nutzungskontext und Beobachtungen. In dieser Musterlösung erscheint nach drei Sekunden ein ruhiger Zusatzhinweis. Acht Sekunden lang nur dieselben Platzhalter zu zeigen, würde den Grund und mögliche nächste Schritte offenlassen.

Ich biete Warten, Filterwechsel und Abbruch an. Einen Retry zeige ich nach einem erkannten Fehler oder einer definierten Zeitüberschreitung; langes Warten allein beweist noch keinen Netzwerkfehler. Ich verspreche weder eine Restdauer noch 90 % Fortschritt ohne entsprechende Daten. Im Test prüfe ich, ob die Rückmeldung verstanden wird und das Weiterarbeiten erlaubt.

Dein Selbstcheck

  • Unterscheidest du „noch keine Daten“, „keine Treffer“ und „Fehler“?
  • Ist der Filterstand sichtbarer alter Ergebnisse eindeutig?
  • Können verspätete Antworten eine neuere Auswahl überschreiben?
  • Bleiben Buchdetails und wichtige Aktionen bedienbar?
  • Gibt es bei Fehlern, Teilergebnissen und langer Wartezeit einen nächsten Schritt?

DEIN MERKSATZ

Ein guter Loading State zeigt mehr als Aktivität.

Er vermittelt, welche Informationen bereits verlässlich sind, was noch aussteht und welche Handlungen weiterhin möglich sind.