Yugen UI

Wait UX

Waiting can't be avoided, but how it feels can be designed. Pick the right signal for each duration, keep loaders from flashing, keep content still, and always offer a way out when something fails.

Perception thresholds

Users don't measure milliseconds; they perceive time. Each duration needs its own kind of signal. A strong signal for a fast task is as bad as silence for a slow one.

DurationHow it feelsWhat to showIn Yugen
< 100msInstant, like flipping a switch.Nothing; show the result directly.Hover / pressed states of controls.
100ms – 1sA delay, but the train of thought holds.No loader, or a light in-place signal (after a delay).useSpinDelay
1 – 3sThe wait becomes noticeable.An in-place spinner, or a skeleton if the layout is known.Button loading · AsyncContent · Skeleton presets
3 – 10sAttention starts to drift.Determinate progress, plus what is happening and how long is left; allow cancel.ProgressBar · PageProgress value
> 10sThe user moves on to something else.Run in the background, let them leave, notify when done.toast · useAnnounce

Pick a duration and press “Generate report”. At 80ms the result appears at once; at 600ms there is only a small spinner in the button (shown after 300ms); at 2s a skeleton; at 6s a progress bar with the time left; at 12s the work moves to the background and a toast reports when it is done.

Choose an indicator

Go from the top and stop at the first “yes”. The options can be combined: Button loading for the button just pressed, PageProgress for the page that is loading.

  1. 1
    Does the action almost always succeed, and can it be undone (like, check off a task, rename)?YesOptimistic UIuseOptimisticList
  2. 2
    Do you know the real progress (bytes sent, steps completed)?YesDeterminate progressProgressBar · PageProgress value
  3. 3
    Is a page navigation or an app-wide background load in progress?YesTop edge barPageProgress · pageProgress.start()
  4. 4
    Do you know the layout of the content that is coming (list, cards, table)?YesSkeletonAsyncContent + Skeleton presets
  5. 5
    Is the wait tied to a control that was just pressed?YesIn-place loadingButton loading
  6. 6
    Anything elseSpinner + label, after a delayLoadingOverlay · Spinner + useSpinDelay

Delay & minimum duration

The two most common loader bugs: it flashes on and off for fast responses, and it flickers when the data arrives just after the loader appears. useSpinDelay fixes both.

Immediate

Not loaded

—
useSpinDelay(300, 500)

Not loaded

—

At 120ms, the left column flashes the skeleton for an instant: the eye sees that something jumped but has no time to read it. At 350ms, the right column waits 300ms before it shows and then stays for the full 500ms, so although the data arrives at 350ms, the user sees one complete transition instead of a blink. From 700ms up, both columns show the loader, but the right one never shows it for less than 500ms.

Prop · TypeDefaultDescription
loadingbooleanDefault: —The actual (raw) loading state.
delaynumberDefault: 300If the response arrives before this, the loader never shows.
minDurationnumberDefault: 500Once the loader shows, it stays at least this long.
(returns)booleanDefault: —true when the loader should be rendered.
import { useSpinDelay, Spinner } from "@/components/ui";

function Orders({ query }: { query: Query }) {
  const { data, isFetching } = useOrders(query);
  // < 300ms: never shown · once shown: kept for ≥ 500ms
  const showSpinner = useSpinDelay(isFetching, { delay: 300, minDuration: 500 });

  return (
    <section aria-busy={isFetching}>
      {showSpinner && <Spinner size="tiny" label="Updating…" />}
      <OrderTable rows={data} />
    </section>
  );
}

While the delay runs, keep the previous UI in place instead of replacing it with an empty area. AsyncContent does this for you, and LoadingOverlay takes a delay so it only covers the old content when the refresh is actually slow.

October revenue

₫128.4M

Up 12% from last month

Skeleton fidelity

A skeleton promises the layout that is coming. If the promise is wrong (a different column count, a bigger avatar, a button somewhere else), everything jumps when the content arrives, which is worse than no skeleton at all.

Real content
  • An NguyenDesigner
  • Binh TranFrontend engineer
  • Chi LeProduct manager
Do: match the layout
Don't: generic blocks
  • Same shape and size: a 40px avatar gets a 40px SkeletonAvatar; 44px table rows get a SkeletonTable with 44px rows.
  • Roughly the same count: 3–5 rows are enough. Don't draw 50 rows for a list that usually has 8 items.
  • Only stand in for what is missing. Show the page title, column labels and navigation buttons you already know right away.
  • Don't put a spinner on top of a skeleton. Use one or the other.
  • Under 300ms no skeleton is needed: let AsyncContent / useSpinDelay skip it.

Optimistic UI & undo

For actions that almost always succeed, don't make the user wait for the server. Change the UI at once, send the request in the background, and roll back only if it fails, with an explanation.

  • Xuan Huong Lake at dawn
  • Cau Dat tea hills
  • Cherry blossoms on the slope

Turn on “Simulate a network error” and star a few photos in a row. The star changes at once; after 1.2 seconds each failed change is rolled back on its own (the other changes stay), and a toast that does not close by itself explains what happened and offers a retry button.

import { useOptimisticList } from "@/components/ui";

const { items, mutate, pending } = useOptimisticList<Photo>(initialPhotos);

// 1. update is applied to the UI at once; write it by id, not by position
// 2. commit runs in the background; on error only this change is rolled back and a toast with a retry button appears
const ok = await mutate(
  (list) => list.map((p) => (p.id === id ? { ...p, starred: !p.starred } : p)),
  () => api.photos.star(id),
  { errorTitle: "Couldn't star the photo" },
);
Good fit for optimistic UIMust wait for the server
Like, star, check off a to-doPayments, money transfers, bookings
Reorder, rename, tagPermanent delete, actions that can't be undone
Send a comment or message (show “sending”)Results the server decides (stock checks, authentication)

Cancel & timeout

A long wait must be cancelable and must not go on forever. When the user cancels, stay silent; on a timeout, say that it took too long and offer a next step.

Data export
Export 12,480 orders to a CSV file (about 6 seconds).
useEffect(() => {
  const controller = new AbortController();
  // Cancel when the user leaves the screen or changes the filter, and stop on its own after 10 seconds.
  const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]);

  fetch(`/api/orders?q=${query}`, { signal })
    .then((res) => res.json())
    .then(setOrders)
    .catch((error) => {
      if (error.name === "AbortError") return;            // cancelled on purpose: stay silent
      if (error.name === "TimeoutError") setStatus("timeout"); // say that it took too long
      else setStatus("error");
    });

  return () => controller.abort();
}, [query]);
  • From about 3 seconds up, always put a Cancel button next to the progress indicator.
  • Cancel the old request when the user changes a filter or keeps typing, so a late result never overwrites a newer one.
  • A timeout is its own state, not an “unknown error”: suggest trying again or running in the background. AbortSignal.any and AbortSignal.timeout have been available in all evergreen browsers since 2024.

Errors & retry

Any wait can end in an error. A good error state answers three questions: what happened, whether any data was lost, and what to do next.

Connection lost

Couldn't load the comments. The post itself is still readable.

The retry button, labelled “Thử lại” (Retry) by default, takes a Promise and goes into its loading state by itself; focus stays on the button the whole time. The first retry still fails (a toast explains why), the second one succeeds.

  • Keep the old data when a refresh fails, and report the error with a toast or MessageBar instead of clearing the screen.
  • Retry transient errors automatically 2–3 times with backoff and jitter; show ErrorState only after the retries run out.
  • Keep the error inside the broken area. One failing widget should not take down the whole page.
  • Write the title in the user's language; technical error codes (if needed) go in the description or behind “Report a problem”.
// Automatic retry for transient errors (network, 5xx, 429), with backoff and a limit.
async function withRetry<T>(task: () => Promise<T>, { retries = 3, base = 500 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await task();
    } catch (error) {
      if (attempt >= retries || !isTransient(error)) throw error;
      const wait = base * 2 ** attempt * (0.5 + Math.random()); // 500 → 1000 → 2000ms, with jitter
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}

// Automatic retries used up → hand control to the user
<AsyncContent status={status} onRetry={reload} skeleton={<SkeletonList rows={5} />}>
  {() => <MemberList members={data!} />}
</AsyncContent>

Accessibility

Screen reader users don't see a spinner turning. The wait has to be announced, briefly and at the right moment, and must never take focus away.

PrincipleHowBuilt into
Mark the busy regionSet aria-busy="true" on the region being updated and remove it when done.LoadingOverlay, AsyncContent, Skeleton
Announce waits that lastA polite announcement, “Đang tải…” (Loading…), when the loader actually shows (after the delay); nothing for fast responses.LoadingOverlay, AsyncContent (useAnnounce)
Read errors right awayrole="alert" for an error after an action the user is waiting on.ErrorState variant="alert" (the default in AsyncContent)
Don't move focusA busy button stays focusable (aria-disabled, not disabled); when content is replaced, focus stays in the region.Button loading, AsyncContent
Respect reduced motionNo shimmer, no width animation and no fade with prefers-reduced-motion.Skeleton, PageProgress, LoadingOverlay
{/* 1. Loading region: aria-busy so assistive technology waits for the update before reading */}
<section aria-busy={isLoading}>…</section>

{/* 2. Announce in words when the wait is long enough to show a loader (LoadingOverlay and AsyncContent do this) */}
const { announce } = useAnnounce();
announce("Loading orders…");
announce(`Loaded ${count} orders`, { batchId: "orders" });

{/* 3. A busy button keeps focus: aria-busy + aria-disabled, not disabled */}
<Button appearance="primary" loading={saving} loadingText="Saving…">Save</Button>

{/* 4. An error after an action the user is waiting on: role="alert" */}
<ErrorState variant="alert" onRetry={retry} />

A progress bar only exposes aria-valuenow when the progress is real. In trickle (estimated) mode, PageProgress leaves the value empty so screen readers don't read out a made-up number.

Do and don't

Do
  • Pick the signal by the expected wait: nothing · in place · skeleton · progress · background.
  • Wrap every loader in useSpinDelay (300ms / 500ms).
  • Keep the old content and the layout still while waiting.
  • Let users cancel long waits, set a timeout, and always offer a way out after an error.
Don't
  • Blocking the whole page with an overlay for a local action.
  • An indeterminate spinner for work whose progress you know.
  • Rolling back an optimistic change without telling the user.
  • Moving focus to the loader, or disabling the button that is busy.