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 |
|---|---|
| CLS | Giữ đúng chỗ → không shift khi nội dung tới |
| LCP | Skeleton KHÔNG phải LCP element; hiện nội dung thật nhanh |
| INP | Placeholder 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ống | Placeholder tốt nhất |
|---|---|
| Submit form, thao tác ngắn | Spinner / button loading state |
| List, lưới card, profile | Skeleton |
| Ảnh | LQIP / blur-up + aspect-ratio |
| Có dữ liệu cache | Progressive (stale-while-revalidate) |
| Mutation lạc quan | Optimistic 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ết | Kết quả |
|---|---|
| Một boundary cho cả trang | Tất-cả-hoặc-không; request chậm nhất chặn hết |
| Một cho mỗi phần lớn | Khuyế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:
- Nhóm nội dung liên quan dưới một boundary để chúng hiện cùng nhau.
- 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-busy và aria-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ỗi | Sửa |
|---|---|
| Kích thước skeleton ≠ nội dung | Xây skeleton từ cùng primitive |
| Flash khi tải nhanh | Trễ hiện skeleton ~200ms |
| Một spinner to chặn trang | Chia thành boundary theo phần |
| Hiệu ứng bỏng ngô | Nhóm nội dung liên quan |
| Screen reader đọc skeleton | aria-hidden trên skeleton |
| Shimmer hại người nhạy cảm chuyển động | prefers-reduced-motion fallback prefers-reduced-motion |
| Skeleton không biến mất khi lỗi | Luô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.