GODRICH
#css-animation#button#loader#transition#scroll#ui#xu-huong-thiet-ke

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

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

Bạn thấy gì

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).

Cú bay lên giờ mang thêm spring overshoot (cubic-bezier(.3, 1.6, .4, 1)) vượt qua vị trí đích rồi mới trở về, và đúng khoảnh khắc đó một quầng radial glow loang ra từ viền ô rồi tan.

Label nổi (floating label)
Mở trong tab mới

01 · Label nổi (floating label) — Diện tích đo được 7.982% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

:placeholder-showntransform scale220ms

Dùng ở đâu

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

Chuyện gì diễn ra trên màn hình

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 vươn quá scaleX(1) một chút rồi cuộn lại theo đường cong spring (cubic-bezier(.2, 1.5, .35, 1)), kèm một quầng radial hình elip loang ra đúng một lần phía dưới ngay khi focus xuất hiện.

Gạch chân giãn ra khi focus
Mở trong tab mới

02 · Gạch chân giãn ra khi focus — Diện tích đo được 1.592% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

:focus-withinscaleX300ms

Chỗ dùng

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)

Chuyển động thế nào

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).

Lúc vào, vòng chạy trước một lượt glow-in spring từ scale(.8) vượt 1.08 rồi cuộn về .92, sau đó vòng thở mới tiếp quản từ đúng vị trí ấy.

Glow bằng :has(:focus)
Mở trong tab mới

03 · Glow bằng :has(:focus) — Diện tích đo được 6.35% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

:has()opacity scale2s 루프

Dùng khi nào

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

Bạn thấy gì

Ô 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.

Chiều rộng giãn theo đường spring (cubic-bezier(.3, 1.4, .4, 1)) vượt quá 240px chút rồi mới vào đúng chỗ, và phía trong viên thuốc một quầng radial glow nở ra đúng một lần.

Ô tìm kiếm mở rộng
Mở trong tab mới

04 · Ô tìm kiếm mở rộng — Diện tích đo được 7.744% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

:focus-withinwidth300ms

Dùng ở đâu

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

Chuyện gì diễn ra trên màn hình

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).

Dòng chữ giờ trượt ra 13px kèm scale(.94) theo spring overshoot (cubic-bezier(.3, 1.6, .4, 1)) vượt qua điểm dừng rồi mới lặng xuống, và một quầng radial glow quét qua ô đúng một lần lúc nó ra đi.

Placeholder trượt
Mở trong tab mới

05 · Placeholder trượt — Diện tích đo được 7.516% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

:placeholder-showntranslate opacity220ms

Chỗ dùng

.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

Chuyển động thế nào

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ỗ.

Cái rung giờ là dao động tắt dần với biên độ giảm từng chu kỳ (1 → .75 → .55 → .34) rồi mới dứt, và khoảnh khắc lỗi xuất hiện một quầng radial đỏ bùng lên quanh khung đúng một lần; :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ế.

Rung khi lỗi
Mở trong tab mới

06 · Rung khi lỗi — Diện tích đo được 2.305% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

:invalidtranslateX600ms

Dùng khi nào

.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

Bạn thấy gì

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].

Select tùy biến mở ra
Mở trong tab mới

07 · Select tùy biến mở ra — Diện tích đo được 18.322% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

aria-expandedscaleY220ms

Dùng ở đâu

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 độ.

Danh sách mở theo spring vượt qua scaleY(1) tới 1.06 rồi cuộn lại, và một quầng radial glow loang xuống từ cạnh dưới của button để xác nhận đúng một lần.

.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ự

Chuyện gì diễn ra trên màn hình

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.

Cú nảy giờ là phản bật spring tắt dần (1.35 → .853 → 1.062 → 1, hệ số tắt .42) vượt qua cỡ gốc hai lần theo hai chiều ngược nhau rồi mới đứng yên; aria-live="polite" gắn kèm để trình đọc màn hình cũng theo dõi được thay đổi này.

Đếm số ký tự
Mở trong tab mới

08 · Đếm số ký tự — Diện tích đo được 1.504% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

maxlengthscale bump140ms

Chỗ dùng

.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

Chuyển động thế nào

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.

Cú đổi đi cùng spring overshoot kéo icon tới 1.12 rồi trả về, và một quầng radial glow nở ra từ tâm button đúng một lần để xác nhận cú bấm.

Nút mắt hiện mật khẩu
Mở trong tab mới

09 · Nút mắt hiện mật khẩu — Diện tích đo được 0.572% · khung hình chuyển động 6/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

type toggleopacity scale220ms

Dùng khi nào

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

Vùng hỗ trợ selector has

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

Nhãn thật và gợi ý autocomplete

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.

Vòng focus cho chuột và bàn phím

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;
}

Giảm chuyển động và trạng thái cuối

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.

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.