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.

Why Placeholders Matter {Vì sao Placeholder quan trọng}

When data is loading {Khi dữ liệu đang tải}, you have three choices {bạn có ba lựa chọn}: show nothing (blank screen) {hiện trang trắng}, show a spinner {hiện spinner}, or show a placeholder that mimics the final layout {hiện placeholder mô phỏng layout cuối cùng}. The third option almost always wins {Lựa chọn thứ ba gần như luôn thắng}.

Perceived Performance {Hiệu năng cảm nhận}

Placeholders don’t make your app faster {Placeholder không làm app nhanh hơn} — they make it feel faster {chúng làm app có cảm giác nhanh hơn}. A skeleton screen tells the user “content is coming, here’s where it will be” {Skeleton screen nói với người dùng “nội dung sắp tới, đây là chỗ nó sẽ hiện”}. This reduces perceived wait time {Điều này giảm thời gian chờ cảm nhận} even when actual load time is identical {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}

Layout Stability (CLS) {Ổn định layout (CLS)}

The biggest technical reason {Lý do kỹ thuật lớn nhất}: placeholders reserve space for incoming content {placeholder giữ chỗ cho nội dung sắp tới}. Without them, content “pops in” and pushes everything down {Không có chúng, nội dung “nhảy vào” và đẩy mọi thứ xuống} — a layout shift that hurts your Cumulative Layout Shift (CLS) score {một layout shift làm hại điểm Cumulative Layout Shift (CLS)} and frustrates users who lose their scroll position or misclick {và gây bực bội cho người dùng mất vị trí scroll hoặc click nhầm}.

The Core Web Vitals Connection {Liên hệ với Core Web Vitals}

Metric {Chỉ số}How placeholders help {Placeholder giúp thế nào}
CLSReserve exact space → zero shift when content arrives {Giữ đúng chỗ → không shift khi nội dung tới}
LCPA skeleton is NOT the LCP element; reveal the real content fast {Skeleton KHÔNG phải LCP element; hiện nội dung thật nhanh}
INPOptimistic placeholders keep the UI responsive during mutations {Placeholder lạc quan giữ UI phản hồi khi mutate}

Live Demo {Demo trực tiếp}

Before the theory, play with it {Trước khi vào lý thuyết, hãy nghịch thử}. The demo below shows three things {Demo dưới đây minh hoạ ba thứ}: the placeholder types side by side {các loại placeholder cạnh nhau}, the flash problem with a 200ms-delay toggle {vấn đề flash với toggle delay 200ms}, and page-level loading islands revealing independently {và loading islands cấp trang hiện độc lập}.

Open the full demo in a new tab {Mở demo đầy đủ ở tab mới}: /tools/placeholder-demo/.


Types of Placeholders {Các loại Placeholder}

1. Spinner / Loader {Spinner / Loader}

A single rotating indicator {Một chỉ báo xoay đơn lẻ}. Cheap to build, but gives no structural hint {Rẻ để làm, nhưng không gợi ý cấu trúc}.

Use when {Dùng khi}: the operation is short (< 1s) {thao tác ngắn}, or the result layout is unknown/variable {hoặc layout kết quả không rõ/thay đổi} — e.g., a button submitting a form {ví dụ nút submit form}.

2. Skeleton Screen {Skeleton Screen}

Gray shapes matching the final content layout {Các hình xám khớp với layout nội dung cuối}, usually with a shimmer animation {thường có animation shimmer}.

Use when {Dùng khi}: the layout is known and stable {layout đã biết và ổn định} — cards, lists, profiles, tables {card, list, profile, table}. This is the default choice for most content {Đây là lựa chọn mặc định cho hầu hết nội dung}.

3. LQIP / Blur-Up {LQIP / Blur-Up}

Low-Quality Image Placeholder {Placeholder ảnh chất lượng thấp}: show a tiny, blurred version of an image {hiện phiên bản nhỏ, mờ của ảnh}, then swap to the full image when loaded {rồi đổi sang ảnh đầy đủ khi tải xong}.

Use when {Dùng khi}: loading images, especially hero images and thumbnails {tải ảnh, đặc biệt ảnh hero và thumbnail}.

4. Progressive / Optimistic {Progressive / Optimistic}

Show partial real data immediately {Hiện một phần dữ liệu thật ngay} (from cache or an optimistic guess) {từ cache hoặc dự đoán lạc quan}, then fill the rest {rồi điền phần còn lại}.

Use when {Dùng khi}: you have cached/stale data {bạn có dữ liệu cache/cũ}, or for mutations like “send message” {hoặc cho mutation như “gửi tin nhắn”}.

Decision Table {Bảng quyết định}

Situation {Tình huống}Best placeholder {Placeholder tốt nhất}
Form submit, short action {Submit form, thao tác ngắn}Spinner / button loading state
List, card grid, profile {List, lưới card, profile}Skeleton
Images {Ảnh}LQIP / blur-up + aspect-ratio
Has cached data {Có dữ liệu cache}Progressive (stale-while-revalidate)
Optimistic mutation {Mutation lạc quan}Optimistic placeholder

Skeleton Anatomy & Layout Stability {Cấu trúc Skeleton & Ổn định layout}

The golden rule {Quy tắc vàng}: a placeholder must occupy the same space as the content it replaces {placeholder phải chiếm cùng không gian với nội dung nó thay thế}. Otherwise you trade a blank screen for a layout shift {Nếu không, bạn đổi trang trắng lấy layout shift}.

Reserve Space with aspect-ratio {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);
}

The Shimmer Effect {Hiệu ứng Shimmer}

A shimmer is a moving gradient that signals “loading” {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%; }

Match the Real Layout {Khớp với layout thật}

Build the skeleton from the same layout primitives as the real component {Xây skeleton từ cùng layout primitive với component thật} so dimensions line up exactly {để 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>

Vanilla JS Implementation {Implement bằng Vanilla JS}

Without a framework, you manage the placeholder lifecycle manually {Không có framework, bạn quản lý vòng đời placeholder thủ công}: show skeleton → fetch → swap to content (or error) {hiện skeleton → fetch → đổi sang content (hoặc lỗi)}.

The Fetch Lifecycle {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();

Avoid the “Flash” Problem {Tránh vấn đề “Flash”}

If data loads in 50ms {Nếu data tải trong 50ms}, the skeleton flashes and disappears {skeleton nhấp nháy rồi biến mất} — jarring {gây khó chịu}. Use a minimum display time OR a delay before showing {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
  }
}

React Implementation {Implement bằng React}

React gives you several patterns {React cho bạn vài pattern}, from manual conditional rendering to declarative Suspense {từ conditional render thủ công đến Suspense khai báo}.

Pattern 1: Conditional Rendering {Pattern 1: Render có điều kiện}

The simplest approach {Cách đơn giản nhất} — track loading state and branch {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: A Reusable Skeleton Component {Pattern 2: Component Skeleton tái dùng}

Build a primitive that any component can compose {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: A Custom Hook {Pattern 3: Custom Hook}

Encapsulate the loading lifecycle (including the anti-flash delay) {Đó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 (Declarative) {Pattern 4: Suspense (Khai báo)}

With a Suspense-enabled data layer (React Query, SWR, RSC, or use()) {Với tầng data hỗ trợ Suspense}, the placeholder becomes a fallback {placeholder trở thành fallback} — no manual if (loading) branches {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>
  );
}

The key benefit {Lợi ích chính}: loading state lives at the boundary, not inside every component {loading state nằm ở boundary, không nằm trong mọi component}. Cleaner and composable {Sạch hơn và kết hợp được}.


Page-Level Orchestration {Tổ chức Placeholder toàn trang}

A real page has many independent data sources {Một trang thật có nhiều nguồn data độc lập}: header, sidebar, main feed, recommendations {header, sidebar, feed chính, gợi ý}. The question is how to coordinate their placeholders {Câu hỏi là làm sao điều phối placeholder của chúng}.

Anti-Pattern: One Big Spinner {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} />;

The slowest request holds the entire page hostage {Request chậm nhất giữ cả trang làm con tin}. Bad UX {UX tệ}.

Principle: Each Component Owns Its Placeholder {Nguyên tắc: Mỗi component sở hữu placeholder của nó}

Co-locate the skeleton with the component that needs it {Đặt skeleton cạnh component cần nó}. Each section loads and reveals independently {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>
  );
}

Boundary Granularity {Độ chi tiết của Boundary}

How finely should you split Suspense boundaries? {Nên chia boundary Suspense mịn đến đâu?}

Granularity {Độ chi tiết}Result {Kết quả}
One boundary for whole page {Một boundary cho cả trang}All-or-nothing; slowest request blocks all {Tất-cả-hoặc-không; request chậm nhất chặn hết}
One per major section {Một cho mỗi phần lớn}Recommended — independent reveal, controlled shifts {Khuyến nghị — hiện độc lập, kiểm soát shift}
One per tiny element {Một cho mỗi element nhỏ}Too many shifts; “popcorn” effect {Quá nhiều shift; hiệu ứng “bỏng ngô”}

Staggered Reveal & Avoiding Popcorn {Hiện so le & Tránh hiệu ứng bỏng ngô}

If too many sections reveal at slightly different times {Nếu quá nhiều phần hiện ở thời điểm hơi khác nhau}, the page “pops” chaotically {trang “nổ” hỗn loạn}. Two fixes {Hai cách sửa}:

  1. Group related content under one boundary {Nhóm nội dung liên quan dưới một boundary} so it reveals together {để chúng hiện cùng nhau}.
  2. Use useDeferredValue / startTransition to keep already-visible content stable {Dùng useDeferredValue / startTransition để giữ nội dung đã hiện ổn định} while new content streams in {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>

The Mental Model {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}

Accessibility {Khả năng truy cập}

Placeholders are visual noise to screen readers {Placeholder là tạp âm với screen reader} if not handled correctly {nếu không xử lý đúng}.

aria-busy and aria-hidden {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>
  );
}
  • aria-busy="true" tells assistive tech “this region is updating” {báo công nghệ hỗ trợ “vùng này đang cập nhật”}
  • aria-hidden="true" on skeletons stops them being announced {trên skeleton ngăn chúng bị đọc lên}
  • aria-live="polite" announces the real content once it arrives {thông báo nội dung thật khi nó tới}

Respect prefers-reduced-motion {Tôn trọng prefers-reduced-motion}

The shimmer animation can trigger discomfort {Animation shimmer có thể gây khó chịu} for motion-sensitive users {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);
  }
}

Common Pitfalls {Các lỗi thường gặp}

Pitfall {Lỗi}Fix {Sửa}
Skeleton size ≠ content size {Kích thước skeleton ≠ nội dung}Build skeleton from same layout primitives; use aspect-ratio {Xây skeleton từ cùng primitive}
Flash on fast loads {Flash khi tải nhanh}Delay showing skeleton ~200ms, or enforce min display time {Trễ hiện skeleton ~200ms}
One giant spinner blocks page {Một spinner to chặn trang}Split into per-section Suspense boundaries {Chia thành boundary theo phần}
Popcorn effect (chaotic reveals) {Hiệu ứng bỏng ngô}Group related content; use useDeferredValue {Nhóm nội dung liên quan}
Screen reader reads skeletons {Screen reader đọc skeleton}aria-hidden on skeletons, aria-busy on region {aria-hidden trên skeleton}
Shimmer hurts motion-sensitive users {Shimmer hại người nhạy cảm chuyển động}prefers-reduced-motion fallback {fallback prefers-reduced-motion}
Skeleton never goes away on error {Skeleton không biến mất khi lỗi}Always handle the error branch {Luôn xử lý nhánh lỗi}

Quick Reference {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

The best placeholder is invisible {Placeholder tốt nhất là vô hình}: the user barely notices the loading happened {người dùng hầu như không nhận ra việc tải đã xảy ra}, because the layout never jumped and the content appeared exactly where the skeleton promised {vì layout không bao giờ nhảy và nội dung hiện đúng chỗ skeleton đã hứa}.