Loading State Motion

Loading state animation giải quyết một vấn đề tâm lý: người dùng không thấy gì xảy ra sẽ nghĩ app bị treo. Animation là bằng chứng rằng hệ thống đang làm việc — dù thực ra nó không làm nhanh hơn chút nào.

Nguyen Ha
9 May 2026

Spring animation không hoạt động như cubic-bezier. Nó settle khi velocity về gần 0 — thay vì kết thúc đúng lúc duration.

Click Load để xem skeleton → content transition (1.5s delay)

Skeleton screen

Placeholder content có hình dạng giống với content thật, với shimmer animation.

.skeleton {
  background: linear-gradient(
    90deg,
    var(--skeleton-base)    0%,
    var(--skeleton-shimmer) 50%,
    var(--skeleton-base)    100%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
  border-radius: 4px;
}

@keyframes skeleton-shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
/* Dark mode tokens */
:root {
  --skeleton-base:    rgba(255, 255, 255, 0.06);
  --skeleton-shimmer: rgba(255, 255, 255, 0.12);
}

Shimmer direction: luôn left-to-right (hướng đọc tự nhiên). Shimmer ngược chiều hoặc vertical gây mất phương hướng.

Khi nào dùng skeleton: khi biết trước cấu trúc content (article, card, list). Skeleton tốt hơn spinner vì giảm “layout shift” — khi data về, skeleton được thay bằng content thật với cùng kích thước.

Spinner

Chỉ dùng khi không biết cấu trúc content, hoặc khi action đang diễn ra (submit form, upload file).

@keyframes spin {
  to { transform: rotate(360deg); }
}

.spinner {
  width: 20px;
  height: 20px;
  border: 2px solid var(--white-10);
  border-top-color: var(--text-neutral-primary);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

linear cho spin animation — ease-in-out tạo spinner trông như “pulsing” theo chu kỳ, không đều.

Inline spinner vs full-page: inline spinner đặt ngay trong button hoặc bên cạnh element đang load. Full-page spinner dùng khi toàn bộ page chưa ready — tránh nếu có thể (skeleton tốt hơn).

Progress bar

Dùng khi có thể estimate percentage hoặc muốn tạo cảm giác progress rõ ràng.

.progress-bar {
  height: 3px;
  background: var(--accent);
  transform-origin: left;
  transition: transform 0.3s ease-out;
}
// Fake progress cho UX (khi không có real percentage)
function fakeProgress() {
  let progress = 0;
  const interval = setInterval(() => {
    progress += Math.random() * 15;
    if (progress >= 85) {
      clearInterval(interval);
      progress = 85; // giữ ở 85%, chờ real completion
    }
    setProgress(progress);
  }, 400);
  return () => clearInterval(interval);
}

Fake progress dừng ở 85% và chờ signal thật — tránh dùng 99% vì người dùng sẽ nhận ra.

Transition sang content thật

Khi data về, transition từ loading state sang content thật cần mượt.

/* Fade content in, skeleton tự disappear khi unmount */
.content-enter {
  animation: fade-in 0.25s ease-out;
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

Nếu content height khác với skeleton height, tránh layout shift bằng cách giữ container height cố định trong quá trình transition.

Animation speed và perception

Các nghiên cứu perception cho thấy:

  • < 400ms: người dùng cảm giác tức thì, không cần loading indicator
  • 400ms – 2s: cần indicator nhưng không cần progress (spinner ok)
  • > 2s: nên có progress feedback hoặc ước lượng thời gian
// Delay hiển thị spinner để tránh flicker với fast loads
const [showSpinner, setShowSpinner] = useState(false);

useEffect(() => {
  if (!isLoading) { setShowSpinner(false); return; }
  const timer = setTimeout(() => setShowSpinner(true), 300);
  return () => clearTimeout(timer);
}, [isLoading]);

Delay 300ms trước khi show spinner: nếu request về trong 300ms, người dùng không thấy loading indicator — smooth.

Lỗi phổ biến

Loop animation quá nhanh: spinner dưới 0.4s/vòng trông như đang “panic”. 0.6–1s là range tự nhiên.

Shimmer với easing không đều: dùng linear hoặc ease-in-out, không dùng ease-out (shimmer sẽ “decelerate” tạo cảm giác kỳ lạ ở cuối mỗi cycle).

Loading state block interaction hoàn toàn: nếu chỉ một phần đang load, không disabled toàn bộ page — chỉ disable phần đó.

Tham khảo: Cubic Bezier chuyên sâu, Stagger Animation, Scroll Reveal Patterns.