GODRICH

9 hiệu ứng css skeleton loading copy-paste

Css skeleton loading là kỹ thuật hiện khối xám giữ đúng hình dạng của nội dung thật trong lúc chờ dữ liệu, thay cho màn hình trắng hoặc spinner đơn điệu.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Thứ tự chín kiểu đi theo đúng luồng của một trang thật khi dữ liệu chưa về kịp. Từ 01 đến 04 là bốn khối tĩnh đứng yên chờ — dòng chữ, card, avatar, bảng — mỗi khối ứng với một vùng giao diện quen thuộc. Mục 05 thêm chuyển động để làm rõ dần một tấm ảnh thay vì chỉ đứng im. Mục 06 giải quyết việc cùng một file skeleton phải vừa với nhiều khung chứa rộng hẹp khác nhau trên cùng một trang. Mục 07 là khoảnh khắc bàn giao — lúc skeleton nhường chỗ cho nội dung thật. Mục 08 và mục 09 là hai biến thể nâng cao hơn: độ trễ theo hàng, và màu tự đổi theo theme của trình duyệt.

01Dòng chữ lấp lánh

Thanh xám thay chỗ cho dòng chữ, còn ánh sáng quét qua nhờ một lớp gradient di chuyển bằng transform chứ không phải đổi màu nền trực tiếp. Dùng cho danh sách bài viết và bình luận, nơi mỗi dòng dài ngắn khác nhau nên các thanh cũng cần rộng hẹp khác nhau để trông giống chữ thật.

1.4s느리게 2s빛 없이
.line {
  position: relative;
  overflow: hidden;
  height: clamp(12px, 4vw, 16px);
  border-radius: $r-control;
  background: rgba($color, .14);

  &::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, transparent, rgba($color, .45), transparent);
    transform: translateX(-120%);
    animation: shimmer $duration $easing infinite;
  }
}

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

02Skeleton dạng card

Một card có sẵn chỗ trống cho ảnh, tiêu đề và mô tả, dùng chung một placeholder %bar cho cả ba khối để chúng lấp lánh cùng kiểu nhưng lệch nhịp nhau đôi chút. Hợp cho card sản phẩm và card bài viết, những nơi người dùng lướt qua rất nhanh và cần thấy ngay bố cục sắp hiện ra.

3장가로형둥글게
%bar {
  position: relative;
  overflow: hidden;
  border-radius: $r-control;
  background: rgba($color, .16);

  &::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, transparent, rgba($stage-paper, .85), transparent);
    transform: translateX(-120%);
    animation: shimmer $duration $easing infinite;
  }
}

.thumb { @extend %bar; aspect-ratio: 2 / 1; }
.title { @extend %bar; height: clamp(12px, 3.6vw, 16px); width: 70%; }
.desc  { @extend %bar; height: clamp(9px, 3vw, 12px); }

03Danh sách avatar

Một avatar tròn cùng hai dòng chữ lặp lại theo từng hàng, còn ánh sáng thì quét một lần cho cả danh sách thay vì cho từng thanh riêng lẻ như hai kiểu trước. Dùng cho khung chat và danh sách người theo dõi, nơi nhiều hàng giống nhau xếp liền kề.

5줄작게글 한 줄
.avatar {
  flex: none;
  width: clamp(20px, 6.4vw, 32px);
  height: clamp(20px, 6.4vw, 32px);
  border-radius: $r-pill;
  background: rgba($color, .22);
}

.sheen {
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, rgba($stage-paper, .4), transparent);
  transform: translateX(-120%);
  animation: shimmer $duration $easing infinite;
  pointer-events: none;
}

04Skeleton bảng

Header của bảng vẫn giữ nguyên chữ thật, chỉ các ô trong thân bảng biến thành thanh xám, nhờ hàng thân và hàng header dùng chung một grid-template-columns nên độ rộng cột không nhảy khi dữ liệu thật thế chỗ. Dùng cho bảng trong dashboard, nơi người dùng cần thấy ngay số cột từ giây đầu tiên.

6행열 폭 유지줄무늬
.row {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1fr;
  gap: clamp(6px, 2vw, 10px);
  padding: clamp(6px, 2vw, 9px) clamp(10px, 3vw, 14px);
  align-items: center;
}

.body {
  position: relative;
  overflow: hidden;
}

.cell {
  height: clamp(9px, 2.6vw, 11px);
  border-radius: $r-control;
  background: rgba($color, .13);
}

05Ảnh mờ dần rõ

Một phiên bản ảnh cực nhỏ được làm mờ bằng filter: blur() rồi phóng to nhẹ để che viền, hiện ra trước tiên; sau đó ảnh thật chồng lên trên và nét dần bằng opacity chứ không đổi kích thước đột ngột. Hợp cho ảnh hero và gallery, nơi một tấm ảnh trắng trơn trông trống trải hơn nhiều so với một vệt màu mờ đúng dáng ảnh.

20px400ms확대 섞기
.placeholder, .real {
  position: absolute;
  inset: 0;
  background-image:
    repeating-linear-gradient(135deg, rgba($subject-cream, .85) 0 6px, rgba($color, .85) 6px 12px),
    radial-gradient(circle at 30% 30%, rgba($subject-cream, .5), transparent 55%);
  background-color: $stage-ink;
}

.placeholder {
  filter: blur(20px);
  transform: scale(1.15);
}

.real {
  opacity: 0;
  transform: scale(1.04);
  animation: reveal $duration $easing infinite;
}

06Skeleton responsive

Số cột của lưới đổi theo độ rộng thật của khung chứa nó, không phải theo độ rộng màn hình, nhờ một container query gắn trên chính .wrap. Cùng một file này đặt trong sidebar hẹp sẽ tự co xuống 2 cột rồi 1 cột, còn đặt trong nội dung chính vẫn giữ nguyên 3 cột, mà không cần viết hai bộ CSS khác nhau.

3열2열 @ 480px1열 @ 320px
.wrap {
  container-type: inline-size;
  container-name: skeleton;
  max-width: 420px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: clamp(6px, 2vw, 10px);
}

@container skeleton (max-width: 260px) {
  .grid { grid-template-columns: repeat(2, 1fr); }
}
@container skeleton (max-width: 150px) {
  .grid { grid-template-columns: 1fr; }
}

07Chuyển sang nội dung

Các thanh skeleton và nội dung thật nằm chồng lên nhau trong cùng một ô grid, rồi lần lượt fade-out và fade-in chứ không dịch chuyển hay đổi kích thước, nên không có cú giật nào khi nội dung thế chỗ. Đây là khung hình cuối của mọi skeleton, chỗ quyết định người dùng có cảm thấy trang "mượt" hay không.

300ms위로 살짝페이드만
.skeleton {
  animation: fade-out $duration $easing infinite;
}

@keyframes fade-out {
  0%, 40%   { opacity: 1; }
  55%, 85%  { opacity: 0; }
  100%      { opacity: 1; }
}

.content {
  animation: fade-in $duration $easing infinite;
}

@keyframes fade-in {
  0%, 40%   { opacity: 0; transform: translateY(6px); }
  55%, 85%  { opacity: 1; transform: translateY(0); }
  100%      { opacity: 0; transform: translateY(6px); }
}

08Lấp lánh theo hàng

Mỗi hàng lấp lánh trễ hơn hàng trước nó đúng 60ms, dùng vòng lặp SCSS @for để gán animation-delay âm cho từng hàng thay vì chép tay chín dòng CSS, nên toàn bộ trông như một con sóng chạy dọc danh sách. Hợp cho danh sách dài, nơi một lần lấp lánh đồng loạt trông đơn điệu hơn nhiều so với nhịp so le.

60ms역방향2열
.bar {
  position: relative;
  overflow: hidden;
  height: clamp(11px, 3.2vw, 14px);
  border-radius: $r-control;
  background: rgba($color, .12);

  &::after {
    content: "";
    position: absolute;
    inset: 0;
    animation: shimmer $duration $easing infinite;
  }
}

@for $i from 1 through 6 {
  .bar:nth-child(#{$i})::after { animation-delay: -($i - 1) * 60ms; }
}

.bar:nth-child(even)::after { animation-name: shimmer-reverse; }

09Tự đổi màu dark mode

Màu nền và màu thanh skeleton tự đổi theo theme của người dùng chỉ nhờ hàm light-dark(), miễn là color-scheme đã khai báo cả hai chế độ trên gốc trang. Hợp cho mọi trang có dark mode, vì không cần viết riêng một khối @media (prefers-color-scheme: dark) chỉ để đổi vài màu skeleton.

light-dark()대비 높게회색 대신 파랑
:root {
  color-scheme: light dark;
}

.card {
  background: light-dark(#ffffff, #1a1a1a);
  box-shadow: $shadow-raised;
}

.bar {
  background: light-dark(rgba(#000, .16), rgba(#fff, .22));
  height: clamp(11px, 3.2vw, 14px);
  border-radius: $r-control;
}

Chỗ dễ hỏng ở đâu? — cái bẫy

Cái bẫy đầu tiên nằm ở mục 06: container query chỉ chạy được từ Chrome 105, Safari 16 và Firefox 110 trở lên, nên trên một trình duyệt cũ hơn thì .grid giữ nguyên 3 cột kể cả khi khung chứa đã hẹp lại, tràn ra ngoài thay vì co gọn. Cái bẫy thứ hai nằm ở mục 09: light-dark() cần Chrome 123, Safari 17.5 hoặc Firefox 120 trở lên, còn thiếu color-scheme: light dark trên :root thì hàm này luôn trả về giá trị sáng dù hệ điều hành đang bật dark mode. Cái bẫy thứ ba nằm ngay ở phần xem trước phía trên: lưới đó đặt trong một khung iframe không cuộn được, nên mỗi ô tự lặp lại vòng chuyển động của nó cứ mỗi hai giây một lần thay vì chờ dữ liệu thật tải xong — bạn chỉ xem chuyển động lặp ở đây thôi, không có gì để bấm cả. Những mốc trình duyệt và số mili giây trong chín kiểu này đã canh theo đúng bản demo thật, và nếu bạn muốn dừng đọc để mở file ngay thì mật khẩu của zip là a3rjf9rd.

Khả năng tiếp cận

Chín demo đều tôn trọng prefers-reduced-motion, nhưng không phải lúc nào cũng bằng cách tắt hẳn chuyển động. Từ 01 đến 04 và mục 08 giữ nguyên opacity ở mức thấy rõ và bỏ animation lấp lánh, để khối skeleton vẫn đứng yên đúng hình dạng chứ không biến mất. Mục 05 và mục 07 khoá thẳng vào trạng thái cuối — ảnh thật hiện rõ, nội dung thật hiện ra — vì một vòng mờ-rõ hay một vòng phai-hiện lặp mãi không đủ tính trang trí để giữ khi người dùng đã tắt chuyển động. Mục 06 giữ nguyên chuyển động co giãn cột vì đó là phản hồi bố cục thật chứ không phải hiệu ứng trang trí, còn mục 09 dừng ở bản dark, không còn phai qua phai lại giữa hai bản màu.

Xem thêm các hiệu ứng CSS khác trong chuyên mục CSS, và trang giới thiệu nói rõ site này thực chất là gì.

FAQ

Css skeleton loading có cần JavaScript không?

Không nhất thiết. Cả chín kiểu ở đây chỉ dùng SCSS thuần — animation, gradient, container query, light-dark() — không có dòng JS nào điều khiển chuyển động. JavaScript chỉ cần khi bạn muốn tự tay bật/tắt skeleton lúc dữ liệu về, còn bản thân hiệu ứng chạy độc lập với việc đó.

Vì sao dùng transform thay vì đổi vị trí left hay width khi lấp lánh?

transform: translateX() chạy trên luồng riêng của trình duyệt (compositor) mà không bắt trang tính lại layout ở mỗi khung hình, còn đổi left hay width thì có. Với một danh sách skeleton dài, đổi layout liên tục dễ làm chuyển động giật, trong khi transform giữ được nhịp mượt suốt lúc chờ tải.

Container query và light-dark() có chạy được trên mọi trình duyệt không?

Chưa phải mọi trình duyệt. Container query cần Chrome 105+, Safari 16+ hoặc Firefox 110+, còn light-dark() cần Chrome 123+, Safari 17.5+ hoặc Firefox 120+. Với site cần hỗ trợ bản trình duyệt cũ hơn, mục 06 vẫn giữ được 3 cột mặc định an toàn còn mục 09 vẫn hiện đúng bản sáng nếu không có color-scheme, nên cả hai không vỡ hẳn, chỉ mất phần nâng cao.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.