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} |
|---|---|
| CLS | Reserve exact space → zero shift when content arrives {Giữ đúng chỗ → không shift khi nội dung tới} |
| LCP | A 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} |
| INP | Optimistic 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}:
- 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}.
- Use
useDeferredValue/startTransitionto keep already-visible content stable {DùnguseDeferredValue/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-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>
);
}
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}.