Yugen UI

Trải nghiệm chờ (Wait UX)

Chờ là không tránh được; cảm giác chờ thì thiết kế được. Chọn đúng tín hiệu cho từng khoảng thời gian, đừng để loader nháy, giữ nội dung đứng yên — và luôn có lối thoát khi lỗi.

Ngưỡng cảm nhận

Người dùng không đo mili-giây, họ cảm nhận. Mỗi khoảng thời gian cần một loại tín hiệu khác nhau — tín hiệu quá mạnh cho việc nhanh cũng tệ như im lặng cho việc chậm.

Thời gianCảm nhậnNên hiển thịTrong Yugen
< 100msTức thì — như bấm công tắc.Không gì cả; cập nhật thẳng kết quả.Trạng thái hover / pressed của control.
100ms – 1sCó độ trễ nhưng dòng suy nghĩ không đứt.Không loader, hoặc tín hiệu nhẹ tại chỗ (sau delay).useSpinDelay
1 – 3sBắt đầu thấy chờ.Spinner tại chỗ hoặc khung xương nếu biết bố cục.Button loading · AsyncContent · Skeleton presets
3 – 10sSự chú ý bắt đầu trôi.Tiến trình xác định + giải thích đang làm gì, còn bao lâu; cho huỷ.ProgressBar · PageProgress value
> 10sNgười dùng sẽ làm việc khác.Chạy nền, cho rời đi, báo khi xong.toast · useAnnounce

Chọn từng mốc rồi bấm “Tạo báo cáo”: 80ms ra kết quả ngay; 600ms chỉ có spinner nhỏ trong nút (hiện sau 300ms); 2 giây có khung xương; 6 giây có thanh tiến trình kèm thời gian còn lại; 12 giây chuyển sang chạy nền và báo bằng toast khi xong.

Chọn loại chỉ báo

Đi từ trên xuống, dừng ở câu trả lời “có” đầu tiên. Các lựa chọn kết hợp được: Button loading cho nút vừa bấm, PageProgress cho trang đang tải.

  1. 1
    Thao tác gần như luôn thành công và hoàn tác được (thích, tích việc, đổi tên)?CóOptimistic UIuseOptimisticList
  2. 2
    Biết tiến độ thật (byte đã gửi, số bước đã xong)?CóTiến trình xác địnhProgressBar · PageProgress value
  3. 3
    Đang chuyển trang hoặc tải nền toàn ứng dụng?CóThanh mép trênPageProgress · pageProgress.start()
  4. 4
    Biết trước bố cục nội dung sắp hiện (danh sách, thẻ, bảng)?CóKhung xươngAsyncContent + Skeleton presets
  5. 5
    Việc chờ gắn với một control vừa bấm?CóLoading tại chỗButton loading
  6. 6
    Còn lạiSpinner + nhãn, sau delayLoadingOverlay · Spinner + useSpinDelay

Delay & thời gian tối thiểu

Hai lỗi phổ biến nhất của loader: nháy lên rồi tắt ngay với phản hồi nhanh, và chập chờn khi dữ liệu về ngay sau lúc loader vừa hiện. useSpinDelay sửa cả hai.

Hiện ngay

Chưa tải

—
useSpinDelay(300, 500)

Chưa tải

—

Với 120ms, cột trái nháy khung xương một thoáng — mắt thấy “có gì đó vừa giật” mà không kịp đọc. Với 350ms, cột phải đợi 300ms mới hiện rồi giữ đủ 500ms, nên dù dữ liệu về ở 350ms, người dùng thấy một chuyển động trọn vẹn thay vì một cái chớp. Từ 700ms trở lên, cả hai cột đều hiện loader — nhưng cột phải không bao giờ dưới 500ms.

Prop · KiểuMặc địnhMô tả
loadingbooleanMặc định: —Trạng thái tải thật (thô).
delaynumberMặc định: 300Phản hồi về trước mốc này → loader không bao giờ hiện.
minDurationnumberMặc định: 500Loader đã hiện thì giữ ít nhất chừng này.
(trả về)booleanMặc định: —true khi nên vẽ loader.
import { useSpinDelay, Spinner } from "@/components/ui";

function Orders({ query }: { query: Query }) {
  const { data, isFetching } = useOrders(query);
  // < 300ms: không bao giờ hiện · đã hiện: giữ ≥ 500ms
  const showSpinner = useSpinDelay(isFetching, { delay: 300, minDuration: 500 });

  return (
    <section aria-busy={isFetching}>
      {showSpinner && <Spinner size="tiny" label="Đang cập nhật…" />}
      <OrderTable rows={data} />
    </section>
  );
}

Trong lúc chờ delay, giữ nguyên giao diện trước đó — đừng thay bằng vùng trống. AsyncContent làm sẵn việc này; và LoadingOverlay nhận delay để phủ lên nội dung cũ chỉ khi việc làm mới thật sự chậm.

Doanh thu tháng 10

128,4 triệu ₫

Tăng 12% so với tháng trước

Độ trung thực của khung xương

Khung xương hứa hẹn bố cục sắp tới. Hứa sai — số cột khác, avatar to hơn, nút nằm chỗ khác — thì lúc nội dung về mọi thứ nhảy chỗ, tệ hơn cả không có khung.

Nội dung thật
  • Nguyễn AnThiết kế
  • Trần BìnhKỹ sư frontend
  • Lê ChiQuản lý sản phẩm
Nên — khớp bố cục
Không nên — khối chung chung
  • Cùng hình dáng và kích thước: avatar 40 thì SkeletonAvatar 40; dòng bảng 44px thì SkeletonTable cũng 44px.
  • Cùng số lượng gần đúng: 3–5 dòng là đủ, đừng vẽ 50 dòng cho danh sách thường có 8 mục.
  • Chỉ dựng phần chưa có; tiêu đề trang, nhãn cột, nút điều hướng đã biết thì hiển thị thật ngay.
  • Không chồng spinner lên khung xương — chọn một trong hai.
  • Dưới 300ms không cần khung xương: để AsyncContent / useSpinDelay bỏ qua.

Optimistic UI & hoàn tác

Với thao tác gần như luôn thành công, đừng bắt người dùng chờ server: đổi giao diện ngay, gửi phía sau, và chỉ quay lại khi thất bại — kèm lời giải thích.

  • Hồ Xuân Hương lúc bình minh
  • Đồi chè Cầu Đất
  • Mai anh đào trên dốc

Bật “Giả lập lỗi mạng” rồi đánh dấu vài ảnh liên tiếp: ngôi sao đổi ngay, sau 1,2 giây từng thay đổi lỗi bị gỡ riêng (các thay đổi khác vẫn giữ) và một toast không tự đóng giải thích chuyện gì đã xảy ra, kèm nút Thử lại.

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

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

// 1. update áp ngay lên giao diện — viết theo id, không theo vị trí
// 2. commit chạy phía sau; lỗi → chỉ thay đổi này bị gỡ, toast "Thử lại" hiện ra
const ok = await mutate(
  (list) => list.map((p) => (p.id === id ? { ...p, starred: !p.starred } : p)),
  () => api.photos.star(id),
  { errorTitle: "Không đánh dấu được ảnh" },
);
Hợp với optimisticPhải chờ server
Thích, đánh dấu sao, tích việc cần làmThanh toán, chuyển tiền, đặt chỗ
Sắp xếp lại, đổi tên, gắn nhãnXoá vĩnh viễn, thao tác không hoàn tác được
Gửi bình luận, tin nhắn (hiện “đang gửi”)Kết quả do server quyết định (kiểm tra tồn kho, xác thực)

Huỷ & timeout

Việc chờ dài phải huỷ được, và không được chờ vô hạn. Huỷ chủ động thì im lặng; quá thời gian thì nói rõ là quá lâu, kèm lựa chọn tiếp theo.

Xuất dữ liệu
Xuất 12.480 đơn hàng ra tệp CSV (khoảng 6 giây).
useEffect(() => {
  const controller = new AbortController();
  // Huỷ khi rời màn hình / đổi bộ lọc, và tự dừng nếu quá 10 giây.
  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;            // huỷ chủ động: im lặng
      if (error.name === "TimeoutError") setStatus("timeout"); // nói rõ là quá lâu
      else setStatus("error");
    });

  return () => controller.abort();
}, [query]);
  • Từ khoảng 3 giây trở lên, luôn có nút Huỷ cạnh chỉ báo tiến trình.
  • Huỷ request cũ khi người dùng đổi bộ lọc / gõ tiếp — kết quả về muộn không được ghi đè kết quả mới.
  • Timeout là một trạng thái riêng, không phải “lỗi không rõ”: gợi ý thử lại hoặc chạy nền. AbortSignal.any và AbortSignal.timeout có trên mọi trình duyệt evergreen từ 2024.

Lỗi & thử lại

Mọi trạng thái chờ đều có thể kết thúc bằng lỗi. Một trạng thái lỗi tốt trả lời ba câu: chuyện gì đã xảy ra, dữ liệu có mất không, và làm gì tiếp theo.

Mất kết nối

Không tải được bình luận. Bài viết vẫn đọc được bình thường.

Nút Thử lại nhận Promise và tự vào trạng thái loading — focus nằm yên trên nút suốt lúc thử. Lần đầu vẫn lỗi (toast giải thích), lần hai thành công.

  • Giữ lại dữ liệu cũ khi làm mới thất bại; báo lỗi bằng toast hoặc MessageBar thay vì xoá trắng màn hình.
  • Tự thử lại 2–3 lần với lùi dần (backoff + jitter) cho lỗi tạm thời; hết lượt mới hiện ErrorState.
  • Lỗi khu trú tại vùng hỏng — một widget lỗi không kéo sập cả trang.
  • Tiêu đề bằng ngôn ngữ người dùng; mã lỗi kỹ thuật (nếu cần) để ở mô tả hoặc trong “Báo lỗi”.
// Thử lại tự động cho lỗi tạm thời (mạng, 5xx, 429) — lùi dần và có giới hạn.
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, có jitter
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}

// Hết lượt tự động → trao quyền cho người dùng
<AsyncContent status={status} onRetry={reload} skeleton={<SkeletonList rows={5} />}>
  {() => <MemberList members={data!} />}
</AsyncContent>

Khả năng tiếp cận

Người dùng trình đọc màn hình không thấy spinner quay. Trạng thái chờ phải được nói ra — vừa đủ, đúng lúc — và không bao giờ được cướp focus.

Nguyên tắcCách làmCó sẵn trong
Đánh dấu vùng bậnĐặt aria-busy="true" lên vùng đang cập nhật, bỏ khi xong.LoadingOverlay, AsyncContent, Skeleton
Nói khi chờ đủ lâuThông báo polite “Đang tải…” khi loader thực sự hiện (sau delay), không thông báo cho phản hồi nhanh.LoadingOverlay, AsyncContent (useAnnounce)
Lỗi được đọc ngayrole="alert" cho lỗi sau thao tác người dùng đang chờ.ErrorState variant="alert" (mặc định trong AsyncContent)
Không di chuyển focusNút đang xử lý vẫn focus được (aria-disabled, không disabled); nội dung bị thay thì focus ở lại vùng.Button loading, AsyncContent
Tôn trọng giảm chuyển độngKhông shimmer, không chạy độ rộng, không fade khi prefers-reduced-motion.Skeleton, PageProgress, LoadingOverlay
{/* 1. Vùng đang tải: aria-busy để công nghệ hỗ trợ chờ cập nhật xong mới đọc */}
<section aria-busy={isLoading}>…</section>

{/* 2. Nói bằng lời khi chờ đủ lâu để thấy loader (LoadingOverlay, AsyncContent tự làm) */}
const { announce } = useAnnounce();
announce("Đang tải đơn hàng…");
announce(`Đã tải ${count} đơn hàng`, { batchId: "orders" });

{/* 3. Nút đang xử lý vẫn giữ focus: aria-busy + aria-disabled, không dùng disabled */}
<Button appearance="primary" loading={saving} loadingText="Đang lưu…">Lưu</Button>

{/* 4. Lỗi sau thao tác người dùng đang chờ: role="alert" */}
<ErrorState variant="alert" onRetry={retry} />

Thanh tiến trình chỉ công bố aria-valuenow khi tiến độ là thật; ở chế độ trickle (ước lượng) PageProgress để trống giá trị để trình đọc màn hình không đọc con số bịa.

Nên & không nên

Nên
  • Chọn tín hiệu theo thời gian chờ dự kiến: không gì · tại chỗ · khung xương · tiến trình · chạy nền.
  • Bọc mọi loader bằng useSpinDelay (300ms / 500ms).
  • Giữ nội dung cũ và bố cục đứng yên trong lúc chờ.
  • Cho huỷ việc chờ dài, đặt timeout, và luôn có lối thoát khi lỗi.
Không nên
  • Không chặn cả trang bằng overlay cho một thao tác cục bộ.
  • Không dùng spinner vô hạn cho việc biết được tiến độ.
  • Không tự hoàn tác thay đổi optimistic mà không báo.
  • Không chuyển focus vào loader, không disable nút đang xử lý.