GODRICH

9 CSS Card Hover Effects — dán 1 dòng là chạy

CSS card hover effects là các hiệu ứng CSS khiến thẻ card phản hồi khi con trỏ dừng trên nó — nổi lên, viền phát sáng, nghiêng theo toạ độ hoặc rọi đèn theo

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

Chín card không xếp theo độ hoành tráng mà theo cách chúng chào bạn: 01 và 02 gây chú ý ngay cả khi bạn chưa chạm vào, 03 đến 05 thêm chiều sâu hoặc lộ thêm nội dung khi bạn dừng lại, 06 đến 08 mời bạn bấm bằng một cử chỉ nhỏ, còn 09 chỉ trả lời khi bạn thật sự nhấn xuống. 02 và 08 tận dụng @property để trình duyệt nội suy mượt góc và toạ độ thay vì nhảy khựng giữa hai giá trị — caniuse ghi Chrome/Edge 85+, Safari 16.4+, Firefox 128+ cho tính năng này. Mỗi mục dưới đây chỉ nêu khối SCSS hoặc JS cốt lõi, bản đầy đủ kèm React nằm trong file nén ở cuối bài.

01Nổi lên + đổ bóng

Card nhấc lên 8px và bóng đổ cũng đậm hơn ngay khi hover, nên nó tách khỏi những card còn lại trong lưới trước cả khi có ai bấm vào. Cách này hợp với lưới card sản phẩm hay danh sách bài viết xếp cạnh nhau, nơi mắt cần biết đang rê tới ô nào.

transform: translateYbox-shadow 확대transition
.card {
  transform: translateY(0);
  transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:hover,
.card:focus-visible,
.card:active {
  transform: translateY(-8px);
  box-shadow: $shadow-raised;
}

02Viền sáng xoay

Một vệt sáng conic-gradient quay đều quanh viền card, còn góc quay được lưu trong biến --angle đăng ký bằng @property rồi biến thiên qua keyframes để không giật khựng giữa các vòng. Kiểu này hợp với card gói cước cao cấp hoặc card giới thiệu tính năng AI, nơi bạn muốn card trông "đang sống" cả trước khi ai chạm vào.

conic-gradient@property angleopacity
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.card__ring {
  background: conic-gradient(from var(--angle), transparent 0deg, $color 40deg, transparent 100deg);
  opacity: 0;
}
.card:hover .card__ring,
.card.is-demo .card__ring {
  opacity: 1;
  animation: spin $duration linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }

03Nghiêng 3D thị sai

Toạ độ con trỏ trên card được JS đọc rồi ghi vào hai biến CSS điều khiển rotateX/rotateY, trong khi lớp nền phía sau dịch chuyển theo hướng ngược lại để trông như hai lớp tách rời nhau. Card giới thiệu tính năng hoặc card dự án portfolio dùng lối này để có cảm giác chiều sâu mà không cần ảnh 3D thật.

perspectiverotateX/rotateYJS 좌표
card.addEventListener('pointermove', function (e) {
  var r = card.getBoundingClientRect();
  var px = (e.clientX - r.left) / r.width - .5;
  var py = (e.clientY - r.top) / r.height - .5;
  card.style.setProperty('--rx', (-py * 14) + 'deg');
  card.style.setProperty('--ry', (px * 16) + 'deg');
  card.style.setProperty('--pz', (px * -12) + 'px');
});

04Nội dung trượt lên

Một panel mô tả vốn nằm khuất dưới translateY(70%) trượt hẳn lên translateY(0) khi hover, để lộ thêm thông tin mà kích thước card không đổi một pixel. Card sản phẩm nặng ảnh hay thumbnail trong gallery hưởng lợi nhiều nhất vì caption không chiếm chỗ lúc bình thường.

transform: translateYoverflow: hiddenopacity
.card__panel {
  transform: translateY(70%);
  transition: transform $duration $easing;
}
.card:hover .card__panel,
.card.is-demo .card__panel {
  transform: translateY(0);
}

05Phóng to ảnh nền

Lớp nền phủ kín card phóng to lên scale(1.12) ngay khi hover, còn overflow: hidden đặt trên chính card cắt sạch phần tràn ra ngoài góc bo. Card giới thiệu điểm đến du lịch hay card cover bài blog dùng cách này để nền có sức sống mà khung ngoài vẫn đứng yên.

transform: scaleoverflow: hiddenborder-radius 유지
.card { overflow: hidden; }
.card__bg {
  transform: scale(1);
  transition: transform $duration $easing;
}
.card:hover .card__bg,
.card.is-demo .card__bg {
  transform: scale(1.12);
}

06Icon nảy

Icon mũi tên ở góc card nảy đúng hai lần bằng keyframes bounce khi hover, như một quả bóng cao su nhỏ, báo rằng card này bấm được. Mũi tên "đọc thêm" trên card blog hay bất kỳ card dạng link nào đều hợp với cử chỉ mời gọi này.

@keyframes bouncecubic-beziertransform: translateY
.card__icon { transform: translateY(0); }
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
  animation: bounce $duration $easing 2;
}
@keyframes bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-8px); }
}

07Viền gradient

Viền xám mỏng lúc bình thường đổi sang viền gradient hai màu khi hover, nhờ opacity của một lớp cắt bằng mask-composite chuyển từ 0 lên 1 mà không đụng vào layout. Card so sánh gói cước hoặc card đề xuất muốn nổi bật dùng đúng kiểu viền này.

mask-compositelinear-gradientopacity
.card__border {
  background: linear-gradient(135deg, $color, $subject-cream);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  opacity: 0;
  transition: opacity $duration $easing;
}
.card:hover .card__border,
.card.is-demo .card__border {
  opacity: 1;
}

08Đèn rọi theo con trỏ

Mười dòng JS đọc toạ độ con trỏ rồi ghi vào hai custom property, và một radial-gradient bám đúng điểm đó để rọi sáng bề mặt card khi bạn rê chuột qua. Card giao diện tối hoặc card tính năng tương tác tận dụng đèn rọi này để mặt card có phản hồi ngay cả khi chưa bấm.

pointermoveCSS 변수radial-gradient
card.addEventListener('pointermove', function (e) {
  var r = card.getBoundingClientRect();
  card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
  card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
  card.style.setProperty('--sp', 1);
});

09Phản hồi khi nhấn

Khi bạn thật sự bấm xuống, card co nhẹ về scale(.97) và bóng đổ phẳng lại tức thì, còn nếu chỉ hover thôi thì không đổi gì cả. Radio dạng card có thể chọn hay bất kỳ card nào dùng trên di động cần đúng xác nhận chạm này trước khi tin rằng cú bấm đã thành công.

:activetransform: scalebox-shadow 축소
.card {
  transform: scale(1);
  transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:active {
  transform: scale(.97);
  box-shadow: $shadow-press;
}

Card hover effects dễ hỏng ở đâu? — cái bẫy mask-composite

Mục 07 là nơi dễ chép nhầm nhất nếu bạn từng thấy một đoạn CSS cũ nào đó viết -webkit-mask-composite: xor;. Cú pháp có tiền tố đó dùng bảng từ khoá riêng của WebKit (source-over, xor, plus-lighter và vài giá trị khác), còn thuộc tính chuẩn không tiền tố mask-composite lại dùng bảng từ khoá khác hẳn — add, subtract, intersect, excludeđúng như MDN liệt kê ở trang mask-composite. Nếu bạn mang nguyên giá trị xor sang dòng không tiền tố, trình duyệt hiện đại coi đó là một từ khoá không hợp lệ và bỏ hẳn khai báo, quay về giá trị khởi tạo add — nghĩa là lớp mask không còn "khoét" phần content-box ra nữa, cả khối gradient phủ kín card thay vì chỉ hiện ra như một viền mỏng. Giá trị đúng tương đương với xor kiểu cũ chính là exclude, và phần không tiền tố này đã chạy được trên Chrome/Edge từ bản 120, Safari từ 15.4, Firefox từ bản 53 theo bảng hỗ trợ của caniuse — đủ rộng để bỏ hẳn tiền tố -webkit- nếu bạn không cần đỡ cho Safari cũ hơn 15.4. Toàn bộ chín file kèm phần chú thích chỗ dễ nhầm này nằm trong bản nén, mật khẩu là 45c9tna8, gõ nguyên văn không thêm khoảng trắng.

Khả năng tiếp cận

Cả chín card đều tôn trọng prefers-reduced-motion: reduce, nhưng không phải card nào cũng chỉ đơn giản "tắt animation". 01, 04, 05, 07 và 09 bỏ transition và giữ nguyên trạng thái cuối — card 01 đứng ở dáng đã nổi lên, 07 hiện viền gradient thường trực. 02 khoá vòng xoay lại ở opacity: .55 cố định thay vì quay liên tục, còn 06 và 09 bỏ hẳn animation nảy hoặc animation vòng lặp trình diễn (.is-demo) để icon và card đứng yên. 03 và 08 vẫn phản hồi qua :focus-visible/:active với một góc nghiêng hoặc một điểm sáng cố định ở giữa, nên người dùng bàn phím vẫn thấy đúng trạng thái cuối như người dùng chuột — chỉ riêng quãng chuyển động dẫn tới đó là biến mất. Mức hỗ trợ của media query này theo từng trình duyệt được MDN ghi ở trang prefers-reduced-motion.

Ngoài chín card này, chuyên mục CSS gom các hiệu ứng khác cùng dạng, còn trang giới thiệu nói rõ vì sao mỗi demo ở đây đều đã đo đạc thật trước khi đăng.

FAQ

Chỉ cần 2-3 hiệu ứng trong 9 cái, tách riêng ra dùng được không?

Được. Mỗi hiệu ứng đóng gói trong đúng một khối .card không chia sẻ trạng thái với mục khác, nên copy phần SCSS của một mục cùng khung HTML đi kèm trong file vanilla là chạy độc lập ngay. Riêng 03 và 08 cần thêm đoạn JS pointermove của chính mục đó vì hai kiểu này không hoạt động được nếu thiếu toạ độ con trỏ.

03 và 08 có phản hồi trên điện thoại chạm không?

Có nhưng khác cơ chế. Sự kiện pointermove không bắn liên tục khi chạm nên hai mục này không "trôi" theo ngón tay, thay vào đó :focus-visible:active bật một góc nghiêng hoặc một điểm sáng cố định ở giữa card khi bạn chạm và giữ, đủ để xác nhận đã chạm đúng chỗ.

Bản React trong zip xử lý toạ độ con trỏ ở 03 và 08 khác gì bản vanilla?

Cả hai component dùng useRef để giữ tham chiếu tới thẻ card và ghi thẳng style.setProperty trong handler pointermove bọc bởi useCallback, giống hệt cách bản vanilla dùng card.style.setProperty. Toạ độ không đi qua state nên card không re-render mỗi lần bạn di chuột, tránh giật khung hình khi rê nhanh.

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.