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

9 hiệu ứng css hamburger menu animation không JS

Css hamburger menu animation là chuyển động của nút ba vạch khi menu mở và đóng, dựng bằng transition và transform của CSS thay vì một thư viện JS.

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

Thứ tự chín mục đi từ trong ra ngoài. Từ 01 đến 04 chỉ đổi hình dạng của icon ba vạch — X thẳng, X xoay, mũi tên, vạch giữa trượt ra — trong khi khối menu bên dưới vẫn hiện ra theo đúng kiểu opacity và transform quen thuộc. Mục 05 và 06 đổi luôn cách cả khối menu xuất hiện, một cái bằng panel toàn màn hình, một cái bằng thuộc tính popover có sẵn của trình duyệt. 07 đến 09 giữ nguyên cách menu hiện ra cơ bản nhưng thêm một lớp hiệu ứng phụ lên trên — link hiện lần lượt, một vòng tròn loang ra, nền phía sau mờ và tối đi. Ở lưới phía trên, bạn xem từng ô tự lặp lại mỗi hai giây bên trong khung iframe; những ô đó không phải nút bấm, và bản thật cần mở riêng file mới nhấn được.

013 vạch chuyển thành X

Bạn thấy gì

Vạch trên và vạch dưới xoay 45 độ quanh tâm rồi chồng lên nhau thành chữ X trong 300ms, còn vạch giữa chỉ scaleX về 0 và mờ dần đi chứ không xoay.

3 vạch chuyển thành X
Mở trong tab mới

01 · 3 vạch chuyển thành X — Diện tích đo được 9.553% · 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

300ms45deg가운데 페이드

Dùng ở đâu

Cả ba đều dùng chung easing $ease-spring nên khớp nhịp với nhau, và đây là kiểu chuyển động mặc định cho một nút hamburger ở header bình thường.

.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }

.stage.is-open .bar1 {
  transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 {
  transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}

02Xoay thành X

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

Cùng phép biến hình X như mục 01, nhưng cả nút .burger tự xoay thêm 180 độ trong lúc ba vạch morph, và easing đổi sang kiểu nảy nhẹ ở cuối nên icon khựng lại một nhịp trước khi dừng hẳn, thay vì dừng êm. 350ms dài hơn mục 01 năm mươi mili giây vì có thêm chuyển động xoay của cả khối cần thời gian để mắt theo kịp.

Xoay thành X
Mở trong tab mới

02 · Xoay thành X — Diện tích đo được 13.016% · 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

180deg350ms역회전

Chỗ dùng

.burger {
  transform: rotate(0deg);
  transition: transform $duration $easing;
}
.stage.is-open .burger { transform: rotate(180deg); }
.stage.is-open .bar1 {
  transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }

03Biến thành mũi tên

Chuyển động thế nào

Ba vạch không gộp thành X mà co lại quanh cạnh phải: vạch trên xoay -35 độ, vạch dưới xoay 35 độ, vạch giữa scaleX còn 0.7, tạo hình một dấu ngoặc nhọn chỉ sang trái trong 250ms.

Biến thành mũi tên
Mở trong tab mới

03 · Biến thành mũi tên — Diện tích đo được 13.206% · 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

250ms오른쪽 화살표각도 30deg

Dùng khi nào

Vì transform-origin đặt ở right center nên các vạch xoay quanh đúng điểm chụm lại chứ không lệch tâm, hợp cho nút vừa mở menu vừa mang nghĩa quay lại.

.bar {
  transform-origin: right center;
}
.stage.is-open .bar1 {
  transform: translate(50%, -50%) translateY(-3px) rotate(-35deg);
}
.stage.is-open .bar2 { transform: translate(50%, -50%) scaleX(.7); opacity: 1; }
.stage.is-open .bar3 {
  transform: translate(50%, -50%) translateY(3px) rotate(35deg);
}

04Vạch giữa trượt ra

Bạn thấy gì

Vạch giữa translateX 140% ra khỏi khung và biến mất trước, rồi mới đến lượt vạch trên dưới xoay chồng thành X — hai bước đó lệch nhau bằng cách chia phần trăm keyframe khác nhau trong cùng 280ms chứ không phải hai animation riêng.

Vạch giữa trượt ra
Mở trong tab mới

04 · Vạch giữa trượt ra — Diện tích đo được 13.229% · 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

오른쪽으로280ms왼쪽으로

Dùng ở đâu

Đóng lại thì thứ tự đảo ngược, X tách ra trước rồi vạch giữa mới trượt về.

@keyframes bar2-loop {
  0%   { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
  8%   { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
  78%  { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
  85%  { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
  100% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
}

05Menu overlay toàn màn hình

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

Bấm nút thì một tấm panel màu cam translateY từ 100% về 0 trong 600ms, phủ kín cả khung và mang theo danh sách link căn giữa, còn ba vạch icon chuyển nhanh hơn nhiều vì bản thân chúng chỉ là phần phụ so với panel đang trồi lên.

Menu overlay toàn màn hình
Mở trong tab mới

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

400ms위로좌우 슬라이드

Chỗ dùng

Hợp cho menu mobile có nhiều mục, nơi một khối dropdown nhỏ không đủ chỗ.

.panel {
  position: absolute; inset: 0;
  transform: translateY(100%);
  transition: transform $duration $easing;
}
.stage.is-open .panel { transform: translateY(0); }

06Menu không cần JS với Popover API

Chuyển động thế nào

Nút .burger chỉ mang thuộc tính popovertarget trỏ đến id của menu, và chính thuộc tính popover trên div đó khiến trình duyệt tự lo việc mở, đóng khi bấm ra ngoài, và đẩy menu lên top layer — không có addEventListener nào điều khiển việc mở đóng thật.

Menu không cần JS với Popover API
Mở trong tab mới

06 · Menu không cần JS với Popover API — Diện tích đo được 12.977% · 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

popoveranchor자동 닫힘

Dùng khi nào

Selector :popover-open định lại vị trí và opacity khi menu đang mở, còn bản chưa mở vẫn nằm yên dưới dạng khối tĩnh bên dưới nút.

.menu {
  position: absolute; top: calc(100% + 8px); left: 0;
  opacity: 0; transform: translateY(-8px) scale(.96);
  transition: opacity $duration $easing, transform $duration $easing;
}
.menu:popover-open {
  position: fixed; inset: auto;
  opacity: 1; transform: translateY(0) scale(1);
}

07Link xuất hiện lần lượt

Bạn thấy gì

Mỗi .menu__link nhận một biến --i riêng đánh số thứ tự, và transition-delay tính bằng calc(var(--i) * 30ms) nên link đầu hiện trước, link cuối hiện sau đúng theo thứ tự xếp dọc chứ không cần bốn animation-delay viết tay.

Link xuất hiện lần lượt
Mở trong tab mới

07 · Link xuất hiện lần lượt — Diện tích đo được 12.543% · 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

30ms역순위에서

Dùng ở đâu

Mỗi link tự dịch chuyển từ translateY(-6px) về 0 trong 220ms, hợp cho menu dropdown có bốn đến sáu mục.

.menu__link {
  opacity: 0; transform: translateY(-6px);
  transition: opacity $duration $ease-spring, transform $duration $ease-spring;
  transition-delay: calc(var(--i) * #{$stagger});
}
.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }

08Lan tỏa hình tròn từ icon

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

Chỉ một thuộc tính clip-path thay đổi bán kính circle từ 0% lên 150% quanh đúng toạ độ góc trên phải nơi icon đứng, nên vòng tròn lan ra từ chính vị trí bấm chứ không phải từ tâm màn hình. 450ms đủ chậm để mắt thấy đường viền tròn loang ra, và vì chỉ một property đổi nên không có property nào khác cần theo dõi riêng.

Lan tỏa hình tròn từ icon
Mở trong tab mới

08 · Lan tỏa hình tròn từ icon — Diện tích đo được 99.997% · 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

clip-path circle450ms오른쪽 위에서

Chỗ dùng

.reveal {
  position: absolute; inset: 0;
  clip-path: circle(0% at calc(100% - 30px) 30px);
  transition: clip-path $duration $easing;
}
.stage.is-open .reveal {
  clip-path: circle(150% at calc(100% - 30px) 30px);
}

09Làm mờ và tối nền

Chuyển động thế nào

Toàn bộ .page phía sau nhận filter: blur(6px) brightness(.6) cùng lúc menu mở ra, và vì hai giá trị đó nằm chung một thuộc tính filter nên chỉ cần một transition, không phải hai.

Làm mờ và tối nền
Mở trong tab mới

09 · Làm mờ và tối nền — Diện tích đo được 20.247% · 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

6px어둡게 40%10px

Dùng khi nào

Cách này khác backdrop-filter ở chỗ nó làm mờ chính nội dung trang chứ không phải một lớp trong suốt nằm trên nội dung, nên không phụ thuộc việc menu có trong suốt hay không.

$blur: 6px;
$dim: .4;

.page {
  transition: filter $dur-base $ease-spring;
}
.stage.is-open .page {
  filter: blur($blur) brightness(1 - $dim);
}

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

filter và backdrop-filter

Cái bẫy đầu tiên nằm ở chỗ nhiều người muốn đổi mục 09 từ filter sang backdrop-filter để chỉ làm mờ lớp nền phía sau một menu trong suốt thay vì mờ luôn cả trang, nhưng mức hỗ trợ trình duyệt của hai thuộc tính đó không giống nhau — backdrop-filter trên MDN ghi rõ nó chạy được từ Chrome 76, từ Safari 18 không cần tiền tố -webkit, còn Firefox chỉ hỗ trợ từ bản 103. Nếu trang của bạn còn phải chạy trên Firefox cũ hơn 103, filter thường trên .page như mục 09 đang dùng vẫn là lựa chọn chắc tay hơn.

Selector :has thay cho JS

Cái bẫy thứ hai là ý định bỏ hẳn nút .burger và JS, dùng selector :has() để cha tự đổi kiểu theo trạng thái con — kiểu .stage:has(.menu:target) hoặc kết hợp checkbox ẩn — nhưng :has() trên MDN cho thấy nó mới chạy từ Chrome 105, từ Safari 15.4, và từ Firefox 121, nên một site cần chạy trên trình duyệt cũ hơn các mốc đó sẽ mất hẳn menu nếu không có phần dự phòng. Chín file trong zip vẫn giữ một dòng JS ngắn để toggle class .is-open, trừ mục 06 dùng thẳng popover, và nếu bạn muốn mở file lên đọc ngay thì mật khẩu của zip là 2cpj9dyd.

Khung iframe 480×300

Cái bẫy thứ ba nằm ở chính lưới xem trước phía trên: khung iframe 480×300 quá nhỏ để bấm và hover được như trên bản thật, nên mỗi ô tự chuyển sang vòng lặp .is-demo dài hai giây thay vì chờ bạn bấm. Bạn chỉ xem chuyển động lặp ở lưới đó thôi; muốn thấy popover của mục 06 tự đóng khi bấm ra ngoài hay vòng tròn của mục 08 loang đúng từ vị trí icon thật, hãy mở riêng file vanilla trong tab của bạn rồi tự bấm vào đó.

Khả năng tiếp cận

Cách tắt chuyển động ở cả chín mục

Cả chín demo đều tắt chuyển động khi prefers-reduced-motion bật, nhưng cách tắt giống nhau ở mọi mục: animation lặp .is-demo bị gỡ hoàn toàn, còn transition-duration của .bar, .menu và các khối phụ như .panel, .reveal, .page bị ép về 1ms thay vì 0ms, để trạng thái mở đóng vẫn đổi đúng lúc bấm mà không có bước chuyển nào lọt vào mắt. Riêng mục 06 không cần đổi gì thêm vì việc mở đóng vốn đã do trình duyệt xử lý qua popover chứ không qua animation của site.

@media (prefers-reduced-motion: reduce) {
  .stage.is-demo .bar1,
  .stage.is-demo .bar2,
  .stage.is-demo .bar3,
  .stage.is-demo .menu { animation: none; }
  .bar, .menu { transition-duration: 1ms; }
}

Xem thêm hiệu ứng CSS khác trong chuyên mục CSS, và trang giới thiệu nói rõ site này thực chất là gì.

FAQ

Css hamburger menu animation có cần JavaScript không?

Gần như không. Tám trong chín mục chỉ dùng một dòng JS ngắn để toggle class .is-open, còn bản thân chuyển động chạy hoàn toàn bằng transition và transform của CSS. Riêng mục 06 dùng thuộc tính popover của trình duyệt nên việc mở đóng không chạm đến JS nào cả.

Nên chọn kiểu bars-to-x, spin hay arrow-morph?

Bars-to-x ở mục 01 là mặc định an toàn cho header thường. Spin ở mục 02 hợp khi bạn muốn nhấn thêm một chút, vì cả khối icon tự xoay chứ không chỉ ba vạch. Arrow-morph ở mục 03 chỉ nên dùng khi nút đó thật sự kiêm luôn chức năng quay lại, vì hình mũi tên gợi ý sai nếu bấm vào chỉ để mở menu.

Có dùng chung với Tailwind hoặc một dự án React được không?

Được. Class tiện ích của Tailwind không đụng vào các @keyframes hay biến SCSS mà đoạn code này khai báo, nên hai bên không giẫm lên nhau. Thư mục react/ trong zip có một component cho mỗi trong chín mục, nhận đúng các biến như $duration hay $easing qua props, nên bạn không cần viết lại phần chuyển động khi chuyển sang React.

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.