Navigation & feedback
Right click a button. If 'Open in new tab' appears, it should be a link.
Links vs. buttons
<a href>for navigation. Supports Cmd+click, middle-click, copy link, bookmarks.<button>for actions on the current page: modals, forms, toggles.
Put state in the URL. Tab state, sort order, filters become shareable, refreshable, and Back-button friendly:
Most common violation: <button onClick={() => router.push('/x')}>. Use <Link> with the route as its href.
Loading delay: 150-300ms before the spinner
Show-delay (~150-300ms)
Show nothing for the first 150-300ms. If the operation finishes, it feels instant.
Minimum-duration (~300-500ms)
Once the spinner appears, keep it for at least 300ms.
Toasts and live regions
- An
aria-live="polite"container so screen readers announce them. - A keyboard-reachable dismiss action.
- Persistence near the field for errors.
The live region must exist in the DOM from page load.
Use aria-live="polite" for almost everything. Reserve assertive for emergencies: session expiry, payment failure.
Destructive confirmation
- Name the action. "Delete project", not "Are you sure?"
- Name the consequences. "This will permanently delete all 47 invoices."
- Make the destructive button visually distinct. Red, secondary position.
For irreversible actions, require typing the resource name:
Optimistic updates
When the outcome is predictable (toggles, likes, stars), update the UI immediately and roll back on failure.
Common mistakes
<button onClick={() => router.push('/x')}>. Loses link semantics. Use<Link>.<a href="#">with a click handler. Looks like a link, isn't one. Use<button>.- Spinner on every render. Fast navigation causes loading flashes.
- No loading state for slow operations. User clicks Submit twice. Two submissions.
- "OK" and "Cancel" on destructive dialogs. Name the action: "Delete project" / "Keep project."
- Error toast that auto-dismisses. Persist errors near the field.