9 CSS Button Click Feedback, dán 1 dòng chạy ngay
CSS button click feedback là chuyển động CSS ngắn xuất hiện khi bạn nhấn hoặc chạm vào một nút, báo hiệu rằng thao tác đã được ghi nhận.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Vòng viền nảy ra ngoài
- 02 Co lại khi nhấn
- 03 Active chỉ dành cho thiết bị cảm ứng
- 04 Giữ lâu mới có phản hồi
- 05 Hàng danh sách sáng lên khi chạm
- 06 Thẻ co nhẹ và tối màu khi chạm
- 07 Rung xác nhận khi chạm
- 08 Checkbox nảy khi chạm
- 09 Link trong đoạn văn sáng lên khi chạm
Thứ tự chín mục dưới đây không xếp theo độ phổ biến mà theo đường đi thật của một cú chạm: vòng phản hồi hiện ra ngay khi ngón tay vừa chạm xuống (01), cảm giác nút lún xuống ngay sau đó (02), phản hồi tách riêng theo loại thiết bị (03), một khoảng chờ xen vào nếu bạn giữ lâu hơn (04), chọn một hàng hoặc một thẻ (05 và 06), một xác nhận ngắn cho lượt thích (07), đổi trạng thái checkbox (08), rồi cuối cùng chạm vào link để sang màn hình khác (09). Lưới 3×3 phía trên chạy lặp bằng class is-demo vì khung iframe cố định không nhận được sự kiện chạm thật, nên mỗi ô tự đổi trạng thái theo chu kỳ thay vì chờ bạn bấm vào đó.
01Vòng viền nảy ra ngoài
Không phải vòng tròn lan ra từ bên trong như ripple, mà một vòng box-shadow phồng dần ra ngoài viền nút rồi tắt hẳn. Vì hiệu ứng phải chạy hết dù ngón tay đã rời đi, một đoạn JS ngắn đổi class ngay trong sự kiện pointerdown thay vì chỉ dựa vào :active.
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($color, .55);
opacity: 0;
}
.btn.is-ringing::after {
animation: ring $duration $easing forwards;
}
@keyframes ring {
0% { box-shadow: 0 0 0 0 rgba($color, .55); opacity: 1; }
100% { box-shadow: 0 0 0 26px rgba($color, 0); opacity: 0; }
}
02Co lại khi nhấn
Trong lúc giữ, nút co về scale(.96) trong 0,2 giây, đồng thời box-shadow cũng lõm xuống theo, buông tay là bật lại ngay, không có độ nảy kiểu lò xo. touch-action: manipulation đi kèm để chặn việc phóng to khi chạm nhanh hai lần liên tiếp trên di động.
.btn {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
touch-action: manipulation;
}
.btn:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
03Active chỉ dành cho thiết bị cảm ứng
Toàn bộ màu nền :active chỉ nằm trong khối @media (hover: none) and (pointer: coarse), nên thiết bị có chuột không bao giờ đọc khối này. Nhờ vậy màn hình di động đổi màu đúng lúc chạm còn máy tính bàn không bao giờ dính lại màu xám sau khi đã bấm xong.
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: $stage-ink;
color: $subject-cream;
}
}
04Giữ lâu mới có phản hồi
Vừa nhấn xuống chưa có gì đổi, transition-delay kéo dài 600ms rồi thẻ mới phóng to và đổ bóng đậm dần lên, tách cú chạm ngắn khỏi một lần giữ thật sự. touch-action: none chặn cử chỉ cuộn trang chen vào đúng lúc bạn đang giữ.
.card {
transition: transform $duration $easing $hold,
box-shadow $duration $easing $hold,
border-color $duration $easing $hold;
touch-action: none;
}
.card:active {
transform: scale(1.06);
box-shadow: $shadow-raised;
border-color: $color;
}
05Hàng danh sách sáng lên khi chạm
-webkit-tap-highlight-color bị tắt trên toàn hàng, thay vào đó màu nền tùy chỉnh qua :active sáng lên ngay rồi tắt dần theo transition riêng, cho biết đúng hàng nào vừa được chạm. Mỗi hàng giữ chiều cao tối thiểu 44px để vừa đúng đầu ngón tay.
.list li {
min-height: 44px;
transition: background $duration $easing;
}
.list li:active {
background: rgba($color, .18);
transition: background 0s;
}
06Thẻ co nhẹ và tối màu khi chạm
Trong lúc chạm, scale(.98) và filter: brightness(.94) chạy cùng lúc nên cả thẻ trông như bị ấn xuống chứ không chỉ đổi viền ngoài. touch-action: manipulation giữ cho vùng chạm rộng của thẻ sản phẩm không bị phóng to khi chạm nhanh hai lần liên tiếp.
.card {
transform: scale(1);
filter: brightness(1);
transition: transform $duration $easing, filter $duration $easing;
touch-action: manipulation;
}
.card:active {
transform: scale(.98);
filter: brightness(.94);
}
07Rung xác nhận khi chạm
Ngay sau khi chạm, nút rung ±2px trong 140ms trong khi icon trái tim đổi từ màu nhạt sang màu đậm — biên độ nhỏ hơn hẳn và mục đích cũng khác so với kiểu rung báo lỗi lắc qua lại ba lần. Đây là một xác nhận thành công ngắn gọn chứ không phải một cảnh báo.
.btn:active {
animation: jitter $duration $easing;
}
.btn:active .btn__icon {
color: $error;
}
@keyframes jitter {
0% { transform: scale(1) translate(0, 0); }
25% { transform: scale(1.18) translate(-2px, 1px); }
50% { transform: scale(1.1) translate(2px, -1px); }
75% { transform: scale(1.16) translate(-1px, 1px); }
100% { transform: scale(1) translate(0, 0); }
}
08Checkbox nảy khi chạm
Chạm vào checkbox tuỳ biến, dấu tick được vẽ dần bằng stroke-dashoffset trong khi cả ô vuông nảy lên scale(1.08) đúng một lần. input[type=checkbox] gốc bị ẩn đi, còn bộ chọn anh em phía sau (~) là cầu nối giữa ô vuông và dấu tick.
.cb__input:checked ~ .cb__box {
border-color: $color;
background: $color;
transform: scale(1.08);
}
.cb__input:checked ~ .cb__box .cb__check {
stroke-dashoffset: 0;
}
.cb__check {
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset $duration $easing;
}
09Link trong đoạn văn sáng lên khi chạm
Chạm vào một link nằm giữa câu văn, một khối màu hiện lên sau chữ rồi tắt trong 140ms, để lại dấu vết đã bấm dù tap-highlight mặc định của trình duyệt đã bị tắt hẳn. Toàn bộ hiệu ứng chỉ đổi mỗi thuộc tính background nên không kéo theo bố cục xung quanh thay đổi theo.
.p__link {
background: transparent;
transition: background $duration $easing;
}
.p__link:active {
background: rgba($color, .32);
transition: background 0s;
}
Bẫy nằm ở đâu?
Nhìn vào keyframe link-flash trong file hiện tại sẽ thấy ba mốc hơi lạ: 0%, 35%, 100% — không chia đều 0%, 50%, 100% như hay viết theo thói quen. Site này đo demo bằng cách so khác biệt màu giữa hai khung hình liền nhau trong vòng lặp 2 giây ở 12fps, và chỉ tính là "có di chuyển" khi khác biệt đó vượt ngưỡng 12 trên thang 0–255 ở ít nhất 6 khung hình. Nếu để nền link đổi alpha tuyến tính (linear) trải đều suốt cả quãng 0% đến 60% thay vì dồn vào 0% đến 35%, quãng đổi màu bị kéo dài ra hơn gấp đôi nên mỗi khung hình đổi màu ít hơn hẳn — có nguy cơ tụt xuống ngay dưới ngưỡng 12 đó dù mắt nhìn demo vẫn thấy màu mờ dần khá rõ. Khi dồn cùng một lượng đổi màu vào cửa sổ hẹp 0% đến 35% như bản hiện tại, số đo thực tế của file này cho ra cường độ trung bình 16.2 trên thang 0–255, với 8 trong 24 khung hình có chuyển động — vượt hẳn ngưỡng 15 và 6 mà site đặt ra. Muốn tự mở file lên để xem đúng ba mốc keyframe đó ngay bây giờ, mật khẩu của bản zip là e8dzq4mp.
Khả năng tiếp cận
Tất cả chín hiệu ứng tắt chuyển động dưới prefers-reduced-motion: reduce nhưng vẫn giữ lại trạng thái đã phản hồi — vòng ở mục 01 không nảy nhưng nút vẫn bấm được bình thường, thẻ ở mục 04 không còn độ trễ nhưng scale cuối vẫn áp dụng ngay khi giữ. Diện tích chạm cũng được tính bằng số chứ không chỉ ước lượng bằng mắt: hàng danh sách ở mục 05 đặt min-height: 44px, còn checkbox ở mục 08 tuy ô vuông hiển thị chỉ 26px nhưng cả label bao quanh vẫn giữ 44px để ngón tay có đủ chỗ chạm, đúng mức tối thiểu mà WCAG 2.5.8 đưa ra. Bốn mục dùng scale hoặc bóng đổ rõ rệt (02, 04, 06, 08) còn có thêm outline :focus-visible riêng cho người dùng bàn phím:
.btn:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 3px;
}
Năm mục còn lại (01, 03, 05, 07, 09) dùng nguyên focus ring mặc định của trình duyệt trên button, li và a nên không cần khai báo lại. Trang MDN về media feature pointer ghi rõ cú pháp (hover: none) and (pointer: coarse) mà mục 03 dùng, còn bảng hỗ trợ trên caniuse xác nhận Chrome, Safari và Firefox đều đọc được media feature này từ nhiều năm nay. Muốn xem thêm hiệu ứng CSS khác thì ghé chuyên mục CSS, còn trang giới thiệu nói rõ vì sao mỗi bài ở đây đều đi kèm một file nén có khoá.
FAQ
Trên iOS Safari, vòng ở mục 01 có hiện ra ngay khi chạm không?
Nếu chỉ dựa vào :active thì có thể chậm — Safari trên iOS cần ít nhất một listener touchstart gắn trên body hoặc một phần tử cha thì mới áp dụng style :active ngay lập tức, theo ghi chú của MDN về :active. Mục 01 không dựa vào :active mà đổi class ngay trong sự kiện pointerdown bằng JavaScript, nên né được luôn vấn đề độ trễ này.
Style chỉ dành riêng cho cảm ứng ở mục 03 hoạt động ra sao trên máy tính bảng vừa có chuột vừa có cảm ứng?
Điều kiện (hover: none) and (pointer: coarse) xét theo thiết bị trỏ chính chứ không xét mọi thiết bị đang cắm vào máy. Nếu máy tính bảng tự báo cảm ứng là đầu vào chính thì khối CSS đó vẫn bật dù có chuột gắn thêm; ngược lại khi chuột trở thành đầu vào chính, khối này tắt hẳn mà không cần thêm dòng nào.
Có thể gộp cả chín hiệu ứng vào một form không?
Được. Vì chúng gắn trên các thẻ khác nhau — button, li, card, checkbox, a — nên đặt chung một màn hình không gây nhầm lẫn cái nào phản hồi cái nào. Chỉ nên tránh gắn đồng thời một hiệu ứng có độ trễ như mục 04 với một hiệu ứng phản hồi tức thì như mục 02 lên cùng một phần tử, vì người dùng sẽ không biết nên chờ phản hồi nào.