9 CSS Input Focus Animation — dán 1 dòng là chạy
CSS input focus animation là các hiệu ứng dùng thẳng trạng thái trình duyệt đã có sẵn — :focus, :focus-within, :placeholder-shown — để ô input phản hồi ngay
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Label nổi (floating label)
- 02 Gạch chân giãn ra khi focus
- 03 Glow bằng :has(:focus)
- 04 Ô tìm kiếm mở rộng
- 05 Placeholder trượt
- 06 Rung khi lỗi
- 07 Select tùy biến mở ra
- 08 Đếm số ký tự
- 09 Nút mắt hiện mật khẩu
Cả chín mục đều dùng thẻ <input> thật, xếp theo mức độ phán định thêm mà ô input cần, chứ không theo độ hoành tráng. Từ 01 đến 05 chỉ cần :focus, :focus-within hoặc :placeholder-shown là đủ, 06 để trình duyệt tự phán định qua HTML5 (:valid/:invalid), còn 07 đến 09 là ba chỗ mà một cú click đổi hẳn trạng thái, nên JavaScript chỉ len vào tối thiểu ở những việc CSS không tự làm được: đếm ký tự, mở danh sách select, đổi kiểu icon mắt.
01Label nổi
Label được đặt làm sibling ngay sau input sẽ bay lên góc trên trái và thu nhỏ khi input đang :focus hoặc đã có giá trị qua :not(:placeholder-shown). Gộp placeholder và label làm một giúp tiết kiệm một dòng trong form chật.
.fl__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
transition: transform $duration $easing, color $duration $easing;
}
.fl__input:focus ~ .fl__label,
.fl__input:not(:placeholder-shown) ~ .fl__label {
transform: translateY(-24px) scale(.78);
color: $color;
}
02Gạch chân giãn ra khi focus
Một span dưới input không viền đóng vai gạch chân: khi phần tử cha .ug rơi vào :focus-within, nó giãn từ scaleX(0) sang scaleX(1) quanh transform-origin: center. Gạch chân mở đều hai bên nên báo đúng ô đang được focus mà không cần đổi màu viền.
.ug__underline {
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: center;
transition: transform $duration $easing;
}
.ug:focus-within .ug__underline { transform: scaleX(1); }
03Glow bằng :has(:focus)
Vòng glow không phải sibling của input mà nằm hẳn bên ngoài, được phần tử cha .fg phát hiện qua :has(.fg__input:focus). Suốt lúc còn focus, vòng này phồng to xẹp nhỏ liên tục bằng opacity và scale như đang thở.
.fg__glow {
border: 2px solid $color;
opacity: 0;
transform: scale(.92);
}
.fg:has(.fg__input:focus) .fg__glow {
animation: glow-pulse $duration $easing infinite;
}
@keyframes glow-pulse {
0%, 100% { opacity: .35; transform: scale(.92); }
50% { opacity: .9; transform: scale(1.04); }
}
04Ô tìm kiếm mở rộng
Ô tìm kiếm dạng viên thuốc giữ 148px lúc bình thường rồi giãn lên 240px ngay khi label bao quanh nó rơi vào :focus-within. Kính lúp bên trái chỉ là một SVG vẽ bằng vòng tròn và một đường thẳng nên không cần icon font.
.se {
width: 148px;
transition: width $duration $easing, border-color $duration $easing;
}
.se:focus-within {
width: 240px;
border-color: $color;
}
05Placeholder trượt
Thuộc tính placeholder thật để trống một khoảng trắng, còn span chồng lên mới là dòng chữ hiển thị. span này trượt sang phải và mờ dần khi input :focus hoặc đã có giá trị qua :not(:placeholder-shown).
.ps__ghost {
transform: translate(0, -50%);
transition: transform $duration $easing, opacity $duration $easing;
}
.ps__input:focus ~ .ps__ghost,
.ps__input:not(:placeholder-shown) ~ .ps__ghost {
transform: translate(10px, -50%);
opacity: 0;
}
06Rung khi lỗi
Input type="email" required mà sai định dạng sau khi đã điền, chỉ riêng phán định :invalid của trình duyệt — không một dòng JS nào — đã làm viền rung và hiện icon X; đúng định dạng thì icon check hiện ở cùng chỗ. :focus-within (MDN) và :placeholder-shown (MDN) đã được mọi engine hỗ trợ ổn định từ lâu, còn :user-invalid vẫn hỗ trợ hạn chế hơn (MDN) nên bài này dùng :invalid:not(:placeholder-shown) thay thế.
.ie__input:invalid:not(:placeholder-shown) {
border-color: $error;
animation: shake $duration $easing;
}
.ie__input:invalid:not(:placeholder-shown) ~ .ie__icon--bad { transform: translateY(-50%) scale(1); opacity: 1; }
.ie__input:valid:not(:placeholder-shown) ~ .ie__icon--ok { transform: translateY(-50%) scale(1); opacity: 1; }
@keyframes shake {
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX($amount * -1); }
50% { transform: translateX($amount * -.7); }
}
07Select tùy biến mở ra
Vì select gốc không cho chỉnh giao diện danh sách, mục này tự dựng button cùng ul[role=listbox]. Click vào button sẽ toggle class is-open bằng 5 dòng JS, danh sách giãn ra qua scaleY còn chevron xoay 180 độ.
.cs__list {
transform: scaleY(0);
transform-origin: top;
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.cs.is-open .cs__list { transform: scaleY(1); opacity: 1; }
.cs__chevron { transition: transform $duration $easing; }
.cs.is-open .cs__chevron { transform: rotate(180deg); }
08Đếm số ký tự
Bản thân CSS không tự đếm được ký tự còn lại, nên mỗi sự kiện input trên textarea viết lại con số rồi gỡ và gắn lại class is-bump để phát lại hiệu ứng nảy. aria-live="polite" gắn kèm để trình đọc màn hình cũng theo dõi được thay đổi này.
.cc__num { display: inline-block; transform: scale(1); }
.cc__count.is-bump .cc__num { animation: bump $duration $easing; }
@keyframes bump {
0% { transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
09Nút mắt hiện mật khẩu
Bấm icon mắt thì type của input thật sự đổi qua lại giữa password và text bằng JS, không phải chỉ đổi giao diện suông. Hai SVG mắt thường và mắt gạch chéo chồng lên nhau, crossfade qua opacity và scale theo class is-shown.
.pw__icon {
transform: scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
.pw__icon--off { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--eye { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--off { transform: scale(1); opacity: 1; }
Ở đâu dễ vỡ nhất — một cái bẫy
Mục 03 và 06 đều dựa vào :has() để phần tử cha phát hiện trạng thái của con. Dưới Chrome·Edge 105, Safari 15.4, Firefox 121, selector này bị bỏ qua hoàn toàn nên vòng glow không sáng và icon check/X cũng không đổi, nhưng input vẫn nhận giá trị bình thường và :invalid vẫn được đánh giá nên chức năng không chết hẳn. Mục 04 lại là chỗ hiếm hoi chuyển thẳng width thay vì transform, nên khi có phần tử đứng cạnh, nó bị đẩy thật sự lúc ô tìm kiếm giãn ra 240px — cũng là lý do bài giới hạn chiều rộng ở mức đó thay vì để tự do. Mật khẩu mở file zip là 4qrx8ve2, gõ đúng như nó hiện trên dòng này, không thêm khoảng trắng nào. Ở mục 06, thiếu required thì ô trống cũng bị tính là :invalid nên viền đỏ ngay khi trang vừa tải xong, vì vậy :not(:placeholder-shown) luôn phải đi kèm.
Khả năng tiếp cận
Mục 01 gắn label for="fl-nick" thật với input id="fl-nick" kèm autocomplete="nickname", mục 06 dùng autocomplete="email", còn mục 09 dùng autocomplete="current-password" để trình duyệt gợi ý đúng loại dữ liệu. p role="alert" ở mục 06 đọc lỗi ngay khi nó xuất hiện, aria-live="polite" ở mục 08 theo dõi số ký tự một cách nhẹ nhàng, còn aria-expanded và aria-pressed ở mục 07 và 09 cập nhật đúng lúc mỗi lần click.
Cả chín mục dùng :focus/:focus-within nên vòng focus mặc định của trình duyệt vẫn hiện với cả chuột lẫn bàn phím. Muốn animation chỉ chạy khi điều hướng bằng bàn phím thì đổi selector sang :focus-visible, một pseudo-class đã được mọi trình duyệt hiện đại hỗ trợ:
.fl__input:focus-visible ~ .fl__label {
outline: 2px solid $color;
outline-offset: 2px;
}
Dưới prefers-reduced-motion: reduce, cả chín mục tắt hết transition/animation nhưng vẫn giữ nguyên trạng thái cuối — label đã nổi, danh sách đã mở, icon đã đổi — nên không mất thông tin nào.
@media (prefers-reduced-motion: reduce) {
.fl__label { transition: none; }
.ie__input { animation: none; }
}
Các hiệu ứng CSS khác nằm trong chuyên mục CSS, còn trang này nói về gì thì xem thêm ở trang giới thiệu.
FAQ
Placeholder có thay được label không?
Không. Placeholder biến mất ngay khi gõ và độ hỗ trợ trình đọc màn hình không ổn định bằng label thật. Mục 01, 04, 06, 08, 09 đều có label thật, còn mục 05 để placeholder trống và chỉ dựa vào span aria-hidden="true" trang trí cùng autocomplete="email" để trình đọc màn hình vẫn nhận ra ô đó.
Dùng chung với Tailwind hoặc dự án React có ổn không?
Ổn. Demo biên dịch từ SCSS ra CSS thuần nên không phụ thuộc công cụ build nào, còn thư mục react/ trong zip có sẵn component riêng cho từng mục; việc phán định focus hoặc valid vẫn do trình duyệt lo, useState chỉ quản những gì CSS không biết như is-open, giá trị textarea, hay type đang hiển thị.
Mục 07, 08, 09 có thật sự cần JavaScript không?
Có, nhưng chỉ ở đúng ba việc CSS không tự làm được: mở và đóng danh sách (07), đếm ký tự (08), và đổi thật thuộc tính type của input (09). Sáu mục còn lại chạy hoàn toàn bằng CSS, không một dòng JS nào.