jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Placeholders & Skeleton Screens — Why They Matter and How to Build Them Right

A bilingual deep-dive into loading placeholders: why they boost perceived performance and prevent layout shift, the types (spinner, skeleton, LQIP), and how to implement and orchestrate them in vanilla JS and React.

Vì sao Placeholder quan trọng

Khi dữ liệu đang tải, bạn có ba lựa chọn: hiện trang trắng, hiện spinner, hiện placeholder mô phỏng layout cuối cùng. Lựa chọn thứ ba gần như luôn thắng.

Hiệu năng cảm nhận

Placeholder không làm app nhanh hơn — chúng làm app có cảm giác nhanh hơn. Skeleton screen nói với người dùng “nội dung sắp tới, đây là chỗ nó sẽ hiện”. Điều này giảm thời gian chờ cảm nhận ngay cả khi thời gian tải thực tế giống hệt.

Blank screen        →  feels broken / slow {cảm giác hỏng / chậm}
Spinner             →  feels generic, no context {chung chung, không ngữ cảnh}
Skeleton placeholder →  feels intentional & fast {có chủ đích & nhanh}

Ổn định layout (CLS)

Lý do kỹ thuật lớn nhất: placeholder giữ chỗ cho nội dung sắp tới. Không có chúng, nội dung “nhảy vào” và đẩy mọi thứ xuống — một layout shift làm hại điểm Cumulative Layout Shift (CLS) và gây bực bội cho người dùng mất vị trí scroll hoặc click nhầm.

Liên hệ với Core Web Vitals

Chỉ sốPlaceholder giúp thế nào
CLSGiữ đúng chỗ → không shift khi nội dung tới
LCPSkeleton KHÔNG phải LCP element; hiện nội dung thật nhanh
INPPlaceholder lạc quan giữ UI phản hồi khi mutate

Demo trực tiếp

Trước khi vào lý thuyết, hãy nghịch thử. Demo dưới đây minh hoạ ba thứ: các loại placeholder cạnh nhau, vấn đề flash với toggle delay 200ms, và loading islands cấp trang hiện độc lập.

Mở demo đầy đủ ở tab mới:


Các loại Placeholder

1. Spinner / Loader

Một chỉ báo xoay đơn lẻ. Rẻ để làm, nhưng không gợi ý cấu trúc.

Dùng khi: thao tác ngắn, hoặc layout kết quả không rõ/thay đổi — ví dụ nút submit form.

2. Skeleton Screen

Các hình xám khớp với layout nội dung cuối, thường có animation shimmer.

Dùng khi: layout đã biết và ổn định — card, list, profile, table. Đây là lựa chọn mặc định cho hầu hết nội dung.

3. LQIP / Blur-Up

Placeholder ảnh chất lượng thấp: hiện phiên bản nhỏ, mờ của ảnh, rồi đổi sang ảnh đầy đủ khi tải xong.

Dùng khi: tải ảnh, đặc biệt ảnh hero và thumbnail.

4. Progressive / Optimistic

Hiện một phần dữ liệu thật ngay từ cache hoặc dự đoán lạc quan, rồi điền phần còn lại.

Dùng khi: bạn có dữ liệu cache/cũ, hoặc cho mutation như “gửi tin nhắn”.

Bảng quyết định

Tình huốngPlaceholder tốt nhất
Submit form, thao tác ngắnSpinner / button loading state
List, lưới card, profileSkeleton
ẢnhLQIP / blur-up + aspect-ratio
Có dữ liệu cacheProgressive (stale-while-revalidate)
Mutation lạc quanOptimistic placeholder

Cấu trúc Skeleton & Ổn định layout

Quy tắc vàng: placeholder phải chiếm cùng không gian với nội dung nó thay thế. Nếu không, bạn đổi trang trắng lấy layout shift.

Giữ chỗ bằng aspect-ratio

/* Image placeholder reserves exact space — no CLS when image loads */
.thumbnail {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: var(--color-surface);
}

Hiệu ứng Shimmer

Shimmer là gradient chuyển động báo hiệu “đang tải”:

.skeleton {
  background: var(--color-surface);
  border-radius: 4px;
  position: relative;
  overflow: hidden;
}

.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(
    90deg,
    transparent,
    rgba(255, 255, 255, 0.06),
    transparent
  );
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  100% { transform: translateX(100%); }
}

/* Common building blocks {Các khối dựng phổ biến} */
.skeleton-text   { height: 0.8em; margin: 0.4em 0; }
.skeleton-title  { height: 1.4em; width: 60%; }
.skeleton-avatar { width: 40px; height: 40px; border-radius: 50%; }
.skeleton-line-short { width: 40%; }

Khớp với layout thật

Xây skeleton từ cùng layout primitive với component thật để kích thước khớp chính xác:

<!-- Real card {Card thật} -->
<article class="card">
  <img class="card-img" src="..." />
  <h3 class="card-title">Real Title</h3>
  <p class="card-body">Real description text...</p>
</article>

<!-- Skeleton card — same structure, same dimensions -->
<article class="card" aria-hidden="true">
  <div class="card-img skeleton" style="aspect-ratio: 16/9;"></div>
  <div class="card-title skeleton skeleton-title"></div>
  <div class="card-body skeleton skeleton-text"></div>
  <div class="card-body skeleton skeleton-text skeleton-line-short"></div>
</article>

Implement bằng Vanilla JS

Không có framework, bạn quản lý vòng đời placeholder thủ công: hiện skeleton → fetch → đổi sang content (hoặc lỗi).

Vòng đời Fetch

const container = document.querySelector("#user-list");

// 1. Reusable skeleton factory {Hàm tạo skeleton tái dùng}
function skeletonCard() {
  return `
    <article class="card" aria-hidden="true">
      <div class="card-img skeleton" style="aspect-ratio:16/9"></div>
      <div class="skeleton skeleton-title"></div>
      <div class="skeleton skeleton-text"></div>
    </article>
  `;
}

// 2. Render N skeletons immediately {Render N skeleton ngay lập tức}
function showSkeletons(count = 6) {
  container.setAttribute("aria-busy", "true");
  container.innerHTML = Array.from({ length: count }, skeletonCard).join("");
}

// 3. Render real content {Render nội dung thật}
function showUsers(users) {
  container.setAttribute("aria-busy", "false");
  container.innerHTML = users
    .map(
      (u) => `
      <article class="card">
        <img class="card-img" src="${u.avatar}" alt="${u.name}" />
        <h3 class="card-title">${u.name}</h3>
        <p class="card-body">${u.bio}</p>
      </article>`
    )
    .join("");
}

// 4. Orchestrate {Điều phối}
async function loadUsers() {
  showSkeletons();
  try {
    const res = await fetch("/api/users");
    if (!res.ok) throw new Error("Failed to load");
    const users = await res.json();
    showUsers(users);
  } catch (err) {
    container.setAttribute("aria-busy", "false");
    container.innerHTML = `<p class="error">Could not load users. Retry?</p>`;
  }
}

loadUsers();

Tránh vấn đề “Flash”

Nếu data tải trong 50ms, skeleton nhấp nháy rồi biến mất — gây khó chịu. Dùng thời gian hiện tối thiểu HOẶC độ trễ trước khi hiện:

// Only show skeleton if loading takes longer than 200ms
// {Chỉ hiện skeleton nếu tải lâu hơn 200ms}
async function loadWithDelay() {
  let settled = false;
  const timer = setTimeout(() => {
    if (!settled) showSkeletons();
  }, 200);

  try {
    const res = await fetch("/api/users");
    const users = await res.json();
    settled = true;
    clearTimeout(timer);
    showUsers(users);
  } catch (err) {
    settled = true;
    clearTimeout(timer);
    // handle error
  }
}

Implement bằng React

React cho bạn vài pattern, từ conditional render thủ công đến Suspense khai báo.

Pattern 1: Render có điều kiện

Cách đơn giản nhất — theo dõi loading state và rẽ nhánh:

function UserList() {
  const [users, setUsers] = useState<User[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then(setUsers)
      .catch(() => setError("Could not load users"));
  }, []);

  if (error) return <ErrorState message={error} />;
  if (!users) return <UserListSkeleton count={6} />;
  return (
    <div className="grid">
      {users.map((u) => (
        <UserCard key={u.id} user={u} />
      ))}
    </div>
  );
}

Pattern 2: Component Skeleton tái dùng

Xây một primitive mà mọi component có thể kết hợp:

type SkeletonProps = {
  width?: string | number;
  height?: string | number;
  radius?: string;
  className?: string;
};

export function Skeleton({
  width = "100%",
  height = "1em",
  radius = "4px",
  className = "",
}: SkeletonProps) {
  return (
    <span
      className={`skeleton ${className}`}
      style={{ width, height, borderRadius: radius }}
      aria-hidden="true"
    />
  );
}

// Compose a domain-specific skeleton {Kết hợp skeleton theo domain}
function UserCardSkeleton() {
  return (
    <article className="card" aria-hidden="true">
      <Skeleton height={0} className="card-img" /> {/* aspect-ratio via CSS */}
      <Skeleton width="60%" height="1.4em" />
      <Skeleton height="0.8em" />
      <Skeleton width="40%" height="0.8em" />
    </article>
  );
}

function UserListSkeleton({ count = 6 }: { count?: number }) {
  return (
    <div className="grid">
      {Array.from({ length: count }, (_, i) => (
        <UserCardSkeleton key={i} />
      ))}
    </div>
  );
}

Pattern 3: Custom Hook

Đóng gói vòng đời loading (gồm cả delay chống flash):

type AsyncState<T> =
  | { status: "idle" | "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
  const [state, setState] = useState<AsyncState<T>>({ status: "loading" });

  useEffect(() => {
    let cancelled = false;
    setState({ status: "loading" });

    fn()
      .then((data) => {
        if (!cancelled) setState({ status: "success", data });
      })
      .catch((error: Error) => {
        if (!cancelled) setState({ status: "error", error });
      });

    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return state;
}

// Usage {Cách dùng}
function UserList() {
  const state = useAsync(() => fetch("/api/users").then((r) => r.json()), []);

  if (state.status === "loading") return <UserListSkeleton />;
  if (state.status === "error") return <ErrorState message={state.error.message} />;
  return <Grid users={state.data} />;
}

Pattern 4: Suspense (Khai báo)

Với tầng data hỗ trợ Suspense, placeholder trở thành fallback — không cần nhánh if (loading) thủ công:

import { Suspense } from "react";

function Page() {
  return (
    <Suspense fallback={<UserListSkeleton count={6} />}>
      <UserList /> {/* This component "suspends" while fetching */}
    </Suspense>
  );
}

Lợi ích chính: loading state nằm ở boundary, không nằm trong mọi component. Sạch hơn và kết hợp được.


Tổ chức Placeholder toàn trang

Một trang thật có nhiều nguồn data độc lập: header, sidebar, feed chính, gợi ý. Câu hỏi là làm sao điều phối placeholder của chúng.

Anti-Pattern: Một Spinner to

// ❌ Whole page blocked until EVERYTHING loads
// {Cả trang bị chặn đến khi MỌI THỨ tải xong}
if (loadingAll) return <FullPageSpinner />;
return <Dashboard data={everything} />;

Request chậm nhất giữ cả trang làm con tin. UX tệ.

Nguyên tắc: Mỗi component sở hữu placeholder của nó

Đặt skeleton cạnh component cần nó. Mỗi phần tải và hiện độc lập:

function Dashboard() {
  return (
    <div className="dashboard">
      {/* Each boundary loads independently {Mỗi boundary tải độc lập} */}
      <Suspense fallback={<HeaderSkeleton />}>
        <UserHeader />
      </Suspense>

      <div className="dashboard-body">
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>

        <Suspense fallback={<FeedSkeleton count={5} />}>
          <Feed />
        </Suspense>

        <Suspense fallback={<RecommendationsSkeleton />}>
          <Recommendations />
        </Suspense>
      </div>
    </div>
  );
}

Độ chi tiết của Boundary

Nên chia boundary Suspense mịn đến đâu?

Độ chi tiếtKết quả
Một boundary cho cả trangTất-cả-hoặc-không; request chậm nhất chặn hết
Một cho mỗi phần lớnKhuyến nghị — hiện độc lập, kiểm soát shift
Một cho mỗi element nhỏQuá nhiều shift; hiệu ứng “bỏng ngô”

Hiện so le & Tránh hiệu ứng bỏng ngô

Nếu quá nhiều phần hiện ở thời điểm hơi khác nhau, trang “nổ” hỗn loạn. Hai cách sửa:

  1. Nhóm nội dung liên quan dưới một boundary để chúng hiện cùng nhau.
  2. Dùng useDeferredValue / startTransition để giữ nội dung đã hiện ổn định trong khi nội dung mới stream vào.
// Group the "above the fold" content so it appears as one unit
// {Nhóm nội dung "trên màn hình đầu" để hiện như một khối}
<Suspense fallback={<HeroSkeleton />}>
  <Hero />
  <PrimaryStats />
</Suspense>

{/* Below-the-fold can stream in separately */}
<Suspense fallback={<FeedSkeleton />}>
  <Feed />
</Suspense>

Mô hình tư duy

Page = composition of independent "loading islands"
{Trang = tập hợp các "đảo loading" độc lập}

┌─────────────────────────────────────────────┐
│  [Header island]      ← own skeleton          │
├──────────────┬──────────────────────────────┤
│ [Sidebar     │  [Feed island]                │
│  island]     │  ← own skeleton, streams       │
│  ← own       │  independently                 │
│  skeleton    │                                │
│              ├──────────────────────────────┤
│              │  [Recommendations island]     │
└──────────────┴──────────────────────────────┘

Each island: reserve space → show skeleton → reveal content
{Mỗi đảo: giữ chỗ → hiện skeleton → hiện nội dung}

Khả năng truy cập

Placeholder là tạp âm với screen reader nếu không xử lý đúng.

aria-busyaria-hidden

function Feed({ loading, items }: FeedProps) {
  return (
    <section aria-busy={loading} aria-live="polite">
      {loading ? (
        // Skeletons are decorative — hide from screen readers
        // {Skeleton là trang trí — ẩn khỏi screen reader}
        <div aria-hidden="true">
          <FeedSkeleton count={5} />
        </div>
      ) : (
        items.map((item) => <FeedItem key={item.id} item={item} />)
      )}
    </section>
  );
}
  • báo công nghệ hỗ trợ “vùng này đang cập nhật”
  • trên skeleton ngăn chúng bị đọc lên
  • thông báo nội dung thật khi nó tới

Tôn trọng prefers-reduced-motion

Animation shimmer có thể gây khó chịu cho người nhạy cảm với chuyển động:

@media (prefers-reduced-motion: reduce) {
  .skeleton::after {
    animation: none;
  }
  /* Use a static subtle background instead {Dùng nền tĩnh nhẹ thay thế} */
  .skeleton {
    background: var(--color-surface);
  }
}

Các lỗi thường gặp

LỗiSửa
Kích thước skeleton ≠ nội dungXây skeleton từ cùng primitive
Flash khi tải nhanhTrễ hiện skeleton ~200ms
Một spinner to chặn trangChia thành boundary theo phần
Hiệu ứng bỏng ngôNhóm nội dung liên quan
Screen reader đọc skeletonaria-hidden trên skeleton
Shimmer hại người nhạy cảm chuyển độngprefers-reduced-motion fallback prefers-reduced-motion
Skeleton không biến mất khi lỗiLuôn xử lý nhánh lỗi

Tham khảo nhanh

1. Reserve space FIRST {Giữ chỗ TRƯỚC}
   → aspect-ratio, fixed dimensions, min-height

2. Match the real layout {Khớp layout thật}
   → same primitives, same sizes

3. Each component owns its placeholder {Mỗi component sở hữu placeholder}
   → co-locate skeleton + Suspense boundary per section

4. Avoid the flash {Tránh flash}
   → 200ms delay before showing skeleton

5. Accessibility {Khả năng truy cập}
   → aria-busy on region, aria-hidden on skeletons,
     prefers-reduced-motion fallback

6. Always handle errors {Luôn xử lý lỗi}
   → loading → success | error, never stuck

Placeholder tốt nhất là vô hình: người dùng hầu như không nhận ra việc tải đã xảy ra, vì layout không bao giờ nhảy và nội dung hiện đúng chỗ skeleton đã hứa.