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
- 01 3 vạch chuyển thành X
- 02 Xoay thành X
- 03 Biến thành mũi tên
- 04 Vạch giữa trượt ra
- 05 Menu overlay toàn màn hình
- 06 Menu không cần JS với Popover API
- 07 Link xuất hiện lần lượt
- 08 Lan tỏa hình tròn từ icon
- 09 Làm mờ và tối nền
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
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. 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
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.
.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
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. 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
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. Đó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
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. 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
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. 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
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. 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
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.
.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
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. 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
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.
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.
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í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.