GODRICH

9 micro interactions css — dán 1 dòng chạy ngay

Micro interactions css là những phản hồi cực nhỏ trên giao diện — nút lún xuống, icon đổi hình, toast xếp chồng — báo cho bạn biết thao tác vừa có tác

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

Thứ tự chín mục đi từ hẹp ra rộng. Từ 01 đến 03 là phản hồi ngay tại chỗ bạn vừa chạm — một nút, một icon lưu, một icon copy — chỉ đổi trạng thái của chính phần tử đó. Từ 04 đến 06 là phản hồi cho những trạng thái không do một cú nhấn trực tiếp quyết định — thích một bài viết, dữ liệu vừa tải xong, danh sách không có kết quả. 07 đến 09 trải rộng trên nhiều phần tử cùng lúc — một ô nhập cùng dòng cảnh báo, một hàng tab, một chồng thông báo. Ở lưới phía trên, bạn xem từng hiệu ứng tự lặp lại — bản đầy đủ, không cắt dòng nào, nằm trong file zip.

01Nút lún xuống khi nhấn

Nút này co lại còn scale(0.96) ngay khi bạn nhấn giữ, nhờ pseudo-class :active chứ không cần JavaScript, rồi bật trở lại bằng easing kiểu lò xo ngay khi bạn thả tay. Dùng cho nút gửi, thẻ có thể bấm, hoặc nút icon — bất cứ chỗ nào cần xác nhận rằng cú chạm đã được ghi nhận.

scale(0.96)스프링 이징active 상태
.btn {
  transform: scale(1);
  box-shadow: $shadow-raised;
  transition: transform $duration $easing, box-shadow $duration $easing;

  &:active {
    transform: scale(.96);
    box-shadow: $shadow-press;
  }

  &.is-demo {
    animation: press-loop $dur-loop $easing infinite;
  }
}

02Hoạt ảnh lưu thành tick

Khi bạn bấm nút lưu, class is-saved bật lên: hình đĩa mềm mờ dần biến mất trong lúc hai vạch của dấu tick tự vẽ ra bằng scaleX, với animation-delay lệch nhau để nét ngắn đi trước nét dài. Một vòng tròn nền cũng phồng lên cùng lúc để nhấn mạnh việc lưu đã xong.

clip-path 모프색상 전환0.3초
&.is-saved &__ring  { animation: ring-pop $duration $easing forwards; }
&.is-saved &__disk  { animation: disk-out $duration $easing forwards; }
&.is-saved &__check-bar--short { animation: check-short $duration $easing forwards; animation-delay: $duration * .35; }
&.is-saved &__check-bar--long  { animation: check-long $duration $easing forwards; animation-delay: $duration * .5; }

@keyframes ring-pop  { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }
@keyframes disk-out  { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(.5); } }
@keyframes check-short { to { transform: rotate(45deg) scaleX(1); } }
@keyframes check-long  { to { transform: rotate(-52deg) scaleX(1); } }

03Phản hồi sao chép xong

Bấm vào icon copy, icon đổi ngay sang dấu tick trong khi hai hình chữ nhật chồng lên nhau mờ dần biến mất, và một tooltip nhỏ nổi lên rồi tự tắt theo đúng bốn mốc của keyframe tip-float. Hợp cho nút copy trong khối code hoặc nút copy API key.

아이콘 스왑툴팁 페이드1.2초 유지
&.is-copied &__ring { animation: ring-pop $duration $easing forwards; }
&.is-copied &__sheet--front { opacity: 0; transform: scale(.6); transition-delay: 0s; }
&.is-copied &__sheet--back  { opacity: 0; transform: scale(.6); }
&.is-copied &__check { animation: check-in $duration $easing forwards; }
&.is-copied &__check-bar--short { animation: bar-short $duration $easing forwards; animation-delay: $duration * .3; }
&.is-copied &__check-bar--long  { animation: bar-long $duration $easing forwards; animation-delay: $duration * .45; }
&.is-copied &__tip { animation: tip-float 1.4s $easing forwards; }

@keyframes tip-float {
  0%   { opacity: 0; transform: translate(-50%, 4px); }
  15%  { opacity: 1; transform: translate(-50%, -2px); }
  85%  { opacity: 1; transform: translate(-50%, -2px); }
  100% { opacity: 0; transform: translate(-50%, -10px); }
}

04Thích lạc quan (tức thời + rollback)

Chạm vào trái tim, giao diện tô đỏ và tăng số đếm ngay lập tức mà không đợi phản hồi server, đúng kiểu optimistic update. Đoạn script còn giả lập luôn cảnh thất bại: sau 800ms trạng thái like tự rollback về ban đầu.

낙관적 업데이트롤백 애니메이션0.8초
var b = document.querySelector('.like');
b.addEventListener('click', function () {
  b.classList.remove('is-demo');
  var liked = b.classList.toggle('is-liked');
  b.setAttribute('aria-pressed', liked);
  if (liked) setTimeout(function () {
    b.classList.remove('is-liked');
    b.setAttribute('aria-pressed', 'false');
  }, 800);
});

05Crossfade skeleton sang nội dung

Hai lớp — khối skeleton xám và nội dung thật — nằm chồng tuyệt đối lên nhau trong cùng một thẻ card, mỗi lớp chỉ đổi opacity và scale riêng để tạo crossfade chứ không lớp nào bị gỡ khỏi DOM. Dùng cho danh sách card đang tải hoặc lần tải đầu của trang hồ sơ.

opacity 크로스페이드겹친 레이어0.4초
.card__layer--skeleton {
  flex-direction: row;
  opacity: 1;
  transform: scale(1);
  transition: opacity $duration $easing, transform $duration $easing;
}
.card__layer--content {
  flex-direction: row;
  align-items: flex-start;
  opacity: 0;
  transform: scale(.98);
  transition: opacity $duration $easing, transform $duration $easing;
}

06Icon trạng thái rỗng nảy nhẹ

Icon khay hiện ra khi danh sách rỗng chỉ dịch chuyển translateY lên xuống 10px theo chu kỳ vô hạn, không có gì khác đổi, để nói rằng màn hình trống này là chủ ý chứ không phải lỗi tải. Hợp cho màn hình không có kết quả tìm kiếm hoặc giỏ hàng rỗng.

translateY 바운스ease-in-out무한 반복
.empty {
  &__icon {
    position: relative;
    width: 52px;
    height: 40px;
    transform: translateY(0);
  }

  &.is-demo &__icon { animation: bounce $duration $easing infinite; }
}

@keyframes bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-10px); }
}

07Ô nhập rung khi sai

Khi giá trị nhập sai, khung input rung translateX qua lại theo năm mốc thời gian bất đối xứng để tạo cảm giác lắc thật, trong khi một vòng viền đỏ tách riêng chỉ đổi opacity thay vì animate border-color. Hợp cho ô mật khẩu ở form đăng nhập hoặc ô số thẻ ở form thanh toán.

translateX 흔들림빨강 테두리0.4초
&.is-error &__frame { animation: shake $duration $easing both; }
&.is-error &__ring  { opacity: 1; }
&.is-error &__msg   { opacity: 1; transform: translateY(0); }

@keyframes shake {
  10%, 90% { transform: translateX(-$amount * .5); }
  20%, 80% { transform: translateX($amount); }
  30%, 70% { transform: translateX(-$amount); }
  40%, 60% { transform: translateX($amount * .7); }
  50%      { transform: translateX(-$amount * .7); }
}

08Vạch chỉ báo tab trượt

Vạch chỉ báo dưới tab dịch chuyển bằng translateX nhân với biến CSS tuỳ chỉnh --i, và click handler trong JavaScript chỉ làm một việc là ghi số thứ tự tab mới vào biến đó. Hợp cho tab trang cài đặt hoặc tab danh mục trên dashboard.

transform 슬라이드width 보간0.25초
&__indicator {
  position: absolute;
  left: 4px; bottom: 4px;
  width: calc((100% - 8px) / 3);
  height: 3px;
  border-radius: $r-pill;
  background: $color;
  transform: translateX(calc(var(--i, 0) * 100%));
  transition: transform $duration $easing;
}

09Toast xếp chồng

Mỗi toast cũ bị đẩy lên translateY(-42px) và thu nhỏ nhẹ bằng scale(.96) mỗi khi có một toast mới xuất hiện phía dưới, nên ba thông báo luôn xếp chồng lên nhau theo bậc thay vì đè lên nhau. Hợp cho thông báo lưu thành công hoặc tiến trình tải nhiều file cùng lúc.

translateY 밀림scale 축소순차 스택
@keyframes slot-1 {
  0%, 4%    { opacity: 0; transform: translateY(16px) scale(1); }
  12%, 38%  { opacity: 1; transform: translateY(0) scale(1); }
  50%, 68%  { opacity: .88; transform: translateY(-42px) scale(.96); }
  80%, 92%  { opacity: .7; transform: translateY(-84px) scale(.92); }
  98%, 100% { opacity: 0; transform: translateY(-84px) scale(.92); }
}

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

Cái bẫy đầu tiên nằm ở mục 09: khung .toasts__list được đặt chiều cao cố định 118px, đúng bằng một slot 34px cộng hai lần dịch chuyển 42px. Con số đó chỉ đủ cho đúng ba toast xếp chồng như trong demo — nếu bạn thêm toast thứ tư vào cùng danh sách mà không tính lại chiều cao, toast mới nhất sẽ tràn ra khỏi khung chứ không tự xếp thêm bậc.

Cái bẫy thứ hai nằm ở mục 03: thời lượng của tooltip là 1.4s viết cứng trong khai báo animation: tip-float 1.4s $easing forwards, tách rời khỏi biến $duration dùng cho icon và dấu tick. Đổi $duration ở đầu file để làm icon đổi nhanh hơn sẽ không kéo tooltip nhanh theo, vì đó là hai con số độc lập chứ không phải một biến chung — nếu bạn muốn mở file ra soi từng dòng ngay bây giờ, mật khẩu của zip là dawuywyc.

Cái bẫy thứ ba nằm ngay ở lưới phía trên: mỗi ô đặt trong iframe cố định thấp và hẹp, nên chín hiệu ứng ở đây đều chạy bằng class .is-demo lặp vô hạn thay cho cú nhấn thật của bạn — bạn chỉ xem chuyển động lặp lại chứ chưa thật sự nhấn nút nào cả. Muốn thấy đúng phản hồi khi bạn tự tay chạm vào, hãy mở file vanilla riêng trong tab của mình.

Khả năng tiếp cận

Chín demo đều phản hồi prefers-reduced-motion, nhưng cách xử lý không phải lúc nào cũng là tắt hẳn — đôi khi là nhảy thẳng đến kết quả cuối. Mục 01, 03, 04, 07 và 09 bỏ animation và giữ transform ở trạng thái cuối cùng chứ không giật về vị trí ban đầu. Mục 02 và 05 khoá luôn ở trạng thái đã lưu và đã tải xong — vòng lặp qua lại giữa hai lớp bị bỏ hẳn. Mục 06 dừng hẳn cú nảy vì một icon đứng yên không mang đủ tính trang trí để đáng giữ khi chuyển động đã tắt, còn mục 08 chỉ bỏ transition của vạch chỉ báo và giữ nguyên vị trí theo biến --i. Hướng dẫn prefers-reduced-motion của MDN có ghi mức hỗ trợ trình duyệt cho media query này, và biến CSS tuỳ chỉnh --i dùng ở mục 08 cũng được MDN giải thích ở đây.

@media (prefers-reduced-motion: reduce) {
  .btn { transition: none; }
  .btn.is-demo { animation: none; }
  .save__ring, .save__disk, .save__check-bar { animation: none !important; }
}

Chín micro interaction này chỉ là một góc nhỏ — chuyên mục CSS gom hết các bài khác cùng kiểu, còn trang giới thiệu giải thích vì sao site chỉ đưa demo đã đo đạc thật.

FAQ

Micro interactions css có cần JavaScript không?

Phần lớn có. Mục 01 chạy hoàn toàn bằng :active nên không cần JavaScript, nhưng 02, 03, 04, 07, 08 và 09 vẫn cần một đoạn script ngắn để toggle class hoặc ghi biến CSS khi bạn nhấn — CSS lo phần chuyển động, JavaScript chỉ lo đúng một việc là đổi trạng thái.

Có dùng chung với React và Tailwind được không?

Được. Thư mục react/ trong zip có sẵn một component cho mỗi hiệu ứng, mỗi component nhận đúng những biến số đó qua props và đi kèm module.scss riêng, nên class tiện ích của Tailwind không xung đột với animation và keyframes của SCSS.

Vì sao demo trong lưới trông khác khi tôi tự bấm trong file thật?

Vì lưới phía trên chạy vòng lặp .is-demo cố định để bạn xem liên tục mà không cần thao tác gì, còn file vanilla thật phản hồi đúng theo cú nhấn hoặc cú chạm của bạn — hai thứ dùng chung SCSS nhưng khác cách kích hoạt.

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.