Die Zustände, die shadcn/ui dir überlässt: Laden, Erfolg, Fehler, leer und reduzierte Bewegung
shadcn/ui gibt dir einen sauberen Button, keinen Lade-Button. Die schwierigen UI-Zustände, die jedes shadcn-Projekt von Hand baut, mit den Timings, die sich richtig anfühlen.
Preet Suthar · · 5 Min. Lesezeit
- React
- shadcn/ui
- UX
- Accessibility
Such nach „shadcn button loading state“, und du findest hundert Snippets, die dasselbe tun: eine isLoading-Prop hinzufügen, das Label gegen einen Spinner tauschen, den Button deaktivieren. Liefere das aus, und deine App fühlt sich schlechter an als vorher.
Das ist keine Kritik an shadcn/ui. Es gibt dir einen sauberen Ausgangspunkt und überlässt dir die Zustände, mit Absicht. Aber „die Zustände“ sind der Ort, an dem der Großteil des Gefühls einer Oberfläche entsteht, und fast niemand plant Zeit dafür ein. Das hier ist die Liste der Zustände, die ich in jeder Komponente behandle, die ich ausliefere, mit den konkreten Timings, die „fertig“ von „funktioniert“ trennen.
1. Laden, das nicht flackert
Der naive Lade-Button zeigt sofort beim Klick einen Spinner. Die meisten Anfragen sind in unter 200ms fertig, der Nutzer sieht also ein Aufblitzen des Spinners und dann das Ergebnis. Das wirkt wie Zittern.
Zwei Regeln beheben das:
- Warte, bevor du den Spinner zeigst. Wenn die Anfrage innerhalb von etwa 150ms fertig ist, zeig ihn gar nicht erst.
- Einmal gezeigt, bleibt er eine Mindestzeit stehen. Etwa 400ms. Ein Spinner, der 30ms lang erscheint, ist schlimmer als gar keiner.
Das ist ein Hook, keine Prop. So sieht er aus:
track nimmt das Promise. Der Hook entscheidet, ob die Anfrage schnell genug war, um den Spinner zu überspringen, hält den Spinner lang genug, damit er nicht flackert, und versetzt den Button in Erfolg oder Fehler, wenn das Promise abgeschlossen ist. Der HextaUI-Hook useButtonFeedback und useDelayedLoading tun genau das, und sie sind klein genug, um sie in einem Rutsch zu lesen.
Dieselbe Verzögerungsregel gilt für Skeletons. Ein Skeleton, das 50ms lang erscheint und dann vom Inhalt ersetzt wird, ist ein graues Aufblitzen. Verzögere es um 150ms, und wenn der Inhalt ankommt, blende ihn ein, ohne etwas drumherum zu verschieben.
2. Erfolg und Fehler, direkt an Ort und Stelle
Nach erfolgreichem Speichern sollte der Button das sagen. „Saved“ mit einem Häkchen, etwa zwei Sekunden lang, dann zurück in den Ruhezustand. Nach einem Fehlschlag sollte der Button das ebenfalls sagen und den Fehler länger halten, etwa vier Sekunden, weil Leute Zeit brauchen, einen Fehler zu lesen und zu entscheiden, was sie tun.
Zwei Details, die wichtiger sind, als sie klingen:
- Nicht zurücksetzen, solange der Zeiger auf dem Button ist. Wer hovert, liest. Setze zurück, nachdem er weg ist.
- Ignoriere Klicks, solange die Anfrage läuft. Nicht mit
disabled, das den Button aus der Tab-Reihenfolge nimmt und Screenreader ihn überspringen lässt. Nutzearia-busyund ignoriere den Klick.
Ein Switch hat dasselbe Problem auf kleinerem Raum. Umschalten, Anfrage senden, und wenn die Anfrage fehlschlägt, zurückschalten. Der Nutzer muss den Rollback sehen, also animiere ihn, und zeig einen kurzen Wartezustand, solange das Speichern unterwegs ist.
3. Ungültig, ohne das Tippen zu bestrafen
Schüttle das Feld, wenn ein Absendeversuch es ungültig vorfindet. Schüttle nie, während jemand noch tippt. Die Regel ist der Auslöser: Validierung bei Submit oder Blur bekommt das Schütteln, Validierung bei Change nicht.
Das Schütteln selbst sollte kurz sein, drei oder vier Schwingungen in etwa 300ms, und unter prefers-reduced-motion wird es zu einem Farbwechsel am Rahmen und sonst nichts.
Natives :user-invalid hilft hier. Es passt erst, nachdem der Nutzer interagiert hat, du kannst den ungültigen Zustand also in CSS stylen, ohne ein touched-Flag in React.
4. Leer ist ein Zustand, keine Abwesenheit
Eine leere Liste ist das Erste, was neue Nutzer sehen, und der Standard ist eine leere Fläche mit einer Überschrift darüber. Jeder leere Zustand braucht drei Dinge: ein Icon oder eine Illustration, damit die Fläche gewollt wirkt, einen Satz, der sagt, was hier erscheinen wird, und die Aktion, die etwas erscheinen lässt.
Das ist billig zu bauen und wird fast immer übersprungen, weil die Person, die das Feature baut, Testdaten hat. Leere deine Datenbank ab und zu.
5. Reduzierte Bewegung heißt nicht „keine Bewegung“
prefers-reduced-motion: reduce ist bei einem nennenswerten Teil der Nutzer aktiv, und die faule Umsetzung ist ein globales * { transition: none }. Das entfernt die Information, die die Bewegung getragen hat. Ein Dialog, der ohne Überblendung aufschnappt, ist schwerer zu erfassen als einer, der einblendet.
Meine Regel: Alles, was sich über den Bildschirm bewegt, wird zu einer Überblendung. Alles, was überblendet, bleibt eine Überblendung. Alles, was in Schleife läuft, stoppt. Dauern werden kürzer, nicht null.
Lege die Prüfung an eine Stelle, damit jede Komponente sie liest:
Und teile auch die Easing-Kurven und Dauern. Wenn jede Komponente ihr eigenes cubic-bezier wählt, fühlt sich die App an, als hätten sie sechs Leute gebaut, weil es so war.
6. Bewegung, die du unterbrechen kannst
Öffne ein Accordion und schließe es mitten in der Öffnungsanimation. In vielen Implementierungen öffnet es sich erst ganz und schließt dann, oder es springt. Richtig wäre, von genau dort umzukehren, wo es gerade ist.
Die Web Animations API macht das einfach: Behalte eine Referenz auf die laufende Animation, und beim nächsten Umschalten liest du ihre aktuelle Zeit und animierst von der aktuell berechneten Höhe aus. CSS-Transitions auf height: auto können das nicht, und grid-template-rows: 0fr zu 1fr kann es, ist aber schwerer mittendrin umzukehren. Wähle den Ansatz, der dich unterbrechen lässt.
7. Touch-Ziele und der iOS-Zoom
Zwei Dinge gehen auf Smartphones kaputt, die du auf einem Laptop nie siehst:
- Inputs unter 16px zoomen die Seite in iOS Safari. Halte Input-Text bei groben Zeigern auf 16px, auch wenn dein Theme sonst 15px nutzt. Eine einzige
@media (pointer: coarse)-Regel reicht. - Tipp-Ziele unter 44px gehen daneben. Buttons können auf dem Desktop 32px hoch sein und auf Touchscreens über dieselbe Media Query wachsen.
8. Rechts-nach-links
Wenn du jemals für arabische oder hebräische Nutzer auslieferst, ist jedes ml- und pl- und left- in deinen Komponenten ein Bug. Nutze logische Eigenschaften: ms-, ps-, start-. shadcn/ui hat im Januar 2026 RTL-Unterstützung hinzugefügt, neue Komponenten, die du ziehst, sind also in Ordnung, aber die, die du 2024 kopiert hast, nicht.
Was das für dich heißt
Nichts davon ist schwer. Alles zusammen sind ein paar Wochen Arbeit an einem Komponentensatz von der Größe von shadcn/ui, und die Arbeit ist unsichtbar, wenn sie fertig ist, weshalb sie gestrichen wird.
Ich habe HextaUI gebaut, weil ich diese Arbeit in jedem Projekt wieder gemacht habe. Es ist eine kostenlose, MIT-lizenzierte Registry, die du zu einem shadcn/ui-Projekt hinzufügst. Gleiche CLI, gleiche Komponentennamen, gleiche Theme-Tokens, und jeder der Zustände oben ist schon behandelt, bis hin zur 150ms Spinner-Verzögerung. Wenn du den Code lieber selbst besitzen und die Zustände selbst schreiben willst: Die Hooks sind eigenständig, und du kannst nur die installieren.
Häufige Fragen
Sollte ich einen Button während des Ladens deaktivieren? Setze aria-busy="true" und ignoriere Klicks. disabled nimmt ihn aus der Tab-Reihenfolge und versteckt ihn mitten in der Aktion vor Screenreadern.
Wie lange sollte ein Erfolgszustand sichtbar sein? Etwa zwei Sekunden, länger, wenn der Zeiger noch auf dem Element ist. Fehler etwa vier Sekunden.
Ist ein Skeleton immer besser als ein Spinner? Ein Skeleton ist besser, wenn du die Form dessen kennst, was kommt. Ein Spinner ist besser für Aktionen. Beide brauchen die 150ms Verzögerung.
Heißt reduzierte Bewegung, dass ich alle Animationen entfernen soll? Nein. Ersetze Bewegung durch Überblendungen, stoppe Schleifen und verkürze Dauern. Behalte die Information.