Navigation & Feedback

10 min

Navigation & Feedback

Navigation and feedback are the two halves of a conversation between the interface and the user. Navigation says "here's where you can go." Feedback says "here's what just happened." Get either wrong and users feel lost—either because they can't find their way or because the system went silent after they acted.

Links vs. buttons

The distinction is semantic, not visual:

  • <a href="..."> (or <Link>) for any element whose primary purpose is to navigate to a different URL. The browser shows the URL on hover, lets the user copy the link, open in a new tab (Cmd+click, middle-click), and bookmark the destination. Screen readers announce it as "link."
  • <button> for any element whose primary purpose is to act on the current page—opening a modal, saving a form, toggling a setting.

If the action takes the user to a different URL—even a different state of the same SPA—it's navigation. Use <a href>. If it stays on the current URL and changes something on the page, use <button>.

The consequence of this discipline: the URL becomes the source of truth for "what page am I on." Tab state, sort order, filter selections, modal open/closed—all of it should live in the URL when the state is meaningful enough to share, refresh, or come back to:

<Link href={`/dashboard?tab=${tab}&sort=${sort}`}>Dashboard</Link>
// vs
<button onClick={() => setTab(t)}>Dashboard</button>

The first version is shareable, refreshable, and the Back button works. The second is ephemeral.

The most common violation: <button onClick={() => router.push('/x')}>. This loses Cmd+click, middle-click, copy-link, bookmark, and screen-reader semantics. Use <Link href="/x"> instead.

Loading delay: 150–300ms before the spinner

A loading state is what the UI shows while waiting. Done well, it communicates progress without creating visual noise. Done badly, it flickers in and out for fast operations and leaves users staring at nothing for slow ones.

Two timing rules tame the flicker:

Show-delay (~150–300ms)

If an operation finishes within ~200ms, the user perceives it as instant. Showing a spinner for 100ms creates a flash that reads as glitchy—spinner appears, content appears. Better to show nothing for the first 150–300ms and commit to a loading state only if the operation is genuinely slow.

const [showSpinner, setShowSpinner] = useState(false);

useEffect(() => {
  const timer = setTimeout(() => setShowSpinner(true), 200);
  return () => clearTimeout(timer);
}, []);

Minimum-duration (~300–500ms)

Once the spinner has appeared, keep it visible for at least 300ms—even if the response arrives 50ms later. A spinner that flashes on and off in 80ms looks broken; it feels worse than no spinner at all.

Loading state timing
Immediate spinnerSpinner on frame 1 — flickers at fast speeds
200ms delayed spinnerSpinner after 200ms — smooth for fast and slow
No spinnerNo feedback at all — broken at slow speeds
150ms
At this speed, the delayed spinner never appears — the request feels instant. The immediate spinner flickers distractingly.

Toasts and live regions

Toast notifications need three things:

  1. An aria-live="polite" container so screen readers announce them. Without this, blind users never know the toast appeared.
  2. A dismiss action reachable from the keyboard. Auto-dismiss after a few seconds doesn't help users who want to read the notification first.
  3. Persistence near the relevant field for errors. A toast that fades after 3 seconds with the only error explanation is useless—the user was still typing and missed it.

The live region should exist in the DOM from page load—don't add the container and the message simultaneously. Screen readers track regions from mount; inserting both at the same instant doesn't reliably trigger an announcement.

<div role="status" aria-live="polite" aria-atomic="true">
  {/* Inject toast text here when events fire */}
</div>

Use aria-live="polite" for almost everything. aria-live="assertive" interrupts whatever the user is doing—reserve it for genuine emergencies (session expiry, payment failure, irrecoverable error).

Destructive confirmation

Any destructive action—deleting data, cancelling a subscription, removing a team member—needs explicit confirmation. The confirmation pattern has three requirements:

  1. Name the action specifically. "Delete project" in the confirmation dialog, not "Are you sure?" The user should know exactly what will happen without reading the body text.
  2. Name the consequences. "This will permanently delete all 47 invoices and cannot be undone."
  3. Make the destructive button visually distinct. Red background, positioned as the secondary action (right side or bottom in LTR layouts). The safe action (Cancel) should be the visually primary button—the one the user hits if they're not paying attention.

For truly irreversible actions, require the user to type the resource name:

Type "production-database" to confirm deletion.

This pattern—used by GitHub, AWS, and Vercel for high-stakes deletions—is friction by design. The cost of an accidental click is high enough to justify it.

Optimistic updates

For interactions where the outcome is predictable—toggling a setting, liking a post, starring a repo—update the UI immediately and roll back on failure. The loading spinner appears only if the request fails, never on the happy path.

This is the highest-leverage perceived-performance trick available. The user sees instant feedback; the network request happens in the background. If it fails, revert the UI and show an error—but for most well-tested APIs, failure rates are below 1%, and the 99% experience is dramatically better.

Common mistakes

  • <button onClick={() => router.push('/x')}>. Loses all link semantics. Use <Link>.
  • <a href="#"> with e.preventDefault() and a click handler. Looks like a link, isn't a link. Use <button>.
  • Spinner on every render. Fast navigation causes repeated brief loading flashes.
  • No loading state for slow operations. User clicks Submit, nothing happens for 4 seconds, they click again—now you have two submissions.
  • Cancel and destructive action labelled "OK" and "Cancel." Both are ambiguous. Name the action: "Delete project" / "Keep project."
  • Toast with the only error explanation that auto-dismisses after 3 seconds. The user missed it. Persist errors near the field.