9 CSS sticky header không cần JS
Css sticky header là kiểu header dùng position: sticky để dính lại trên đầu màn hình khi bạn cuộn trang, thay vì trôi mất theo nội dung.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Ẩn rồi hiện lại
- 02 Đổ bóng khi cuộn
- 03 Đảo màu khi cuộn
- 04 Chỉ logo thu nhỏ
- 05 Gắn thanh tiến độ
- 06 Sub-nav dính theo
- 07 Tiêu đề section dính theo
- 08 Header backdrop blur
- 09 Header đồng bộ scroll-snap
Chín kiểu này không xếp theo mức phổ biến mà theo trình tự mà một header thật sự trải qua khi bạn cuộn trang: nhận ra hướng cuộn (01), báo hiệu đã rời khỏi đỉnh trang (02), đổi hẳn diện mạo qua khỏi hero (03), giảm bớt sức nặng thị giác (04), dẫn dắt tiến độ đọc (05), mở rộng thêm một tầng điều hướng (06), cập nhật đúng ngữ cảnh đang xem (07), cho nền phía sau xuyên qua (08), rồi báo đúng vị trí đang đứng trong một trang chia section (09). Mỗi khối code dưới đây chỉ giữ phần lõi tạo nên chuyển động; bản đầy đủ, không cắt dòng nào, nằm trong file zip.
01Ẩn rồi hiện lại
Một đoạn JS ngắn theo dõi hướng cuộn: cuộn xuống thì gắn class .is-hidden khiến header trượt lên bằng transform: translateY(-100%), còn cuộn ngược lên thì gỡ ngay class đó. Hợp cho header bài viết dài hoặc thanh trên cùng của trang mua sắm trên di động, nơi header không nên chiếm màn hình quá lâu.
var head = document.querySelector('.head');
var lastY = window.scrollY;
window.addEventListener('scroll', function () {
var y = window.scrollY;
head.classList.toggle('is-hidden', y > lastY && y > 40);
lastY = y;
}, { passive: true });
02Đổ bóng khi cuộn
Một sentinel cao 1px được đặt ngay đỉnh trang; IntersectionObserver theo dõi sentinel đó và bật opacity của lớp gradient phía dưới header lên 1 ngay khi sentinel rời khung nhìn. Hợp cho header tài liệu hoặc blog, nơi nền nhạt cần một đường ranh giới rõ với nội dung.
.head__shadow {
position: absolute;
left: 0; right: 0; bottom: -48px;
height: 48px;
background: linear-gradient(rgba($ink, .85), rgba($ink, 0));
opacity: 0;
transition: opacity $duration $easing;
}
.head.is-scrolled .head__shadow { opacity: 1; }
03Đảo màu khi cuộn
Hai lớp nền được đặt chồng lên nhau — một trong suốt, một có màu đặc — và khi qua khỏi hero thì lớp trong suốt mờ dần trong lúc lớp có màu hiện lên, nên cả header đảo màu cùng lúc. Hợp cho trang landing có hero toàn màn hình hoặc đầu trang portfolio.
.head__layer--dark {
opacity: 1;
transition: opacity $duration $easing;
}
.head__layer--light {
background: $color;
opacity: 0;
transition: opacity $duration $easing;
}
.head.is-inverted .head__layer--dark { opacity: 0; }
.head.is-inverted .head__layer--light { opacity: 1; }
04Chỉ logo thu nhỏ
Chiều cao và nền của header giữ nguyên suốt quá trình cuộn, chỉ riêng logo co lại theo scale nhờ animation-timeline: scroll(root), nên layout không hề xê dịch dù logo đổi kích thước. Hợp cho trang landing SaaS có logo lớn, nơi header cần tránh layout shift bằng mọi giá.
.head__logo {
transform-origin: left center;
@supports (animation-timeline: scroll()) {
animation: logo-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0 150px;
}
}
@keyframes logo-shrink {
from { transform: scale(1); }
to { transform: scale($scale-to); }
}
05Gắn thanh tiến độ
Thanh mỏng ở mép dưới header là phần tử con của chính header đó nên cả hai luôn di chuyển liền một khối, và animation-timeline: scroll(root) lấp đầy nó theo đúng tiến độ đọc mà không cần listener tính lại mỗi frame. Hợp cho header bài blog dài hoặc đầu trang tài liệu hướng dẫn.
.head__bar {
position: absolute;
left: 0; bottom: -$thickness;
width: 100%; height: $thickness;
background: $color;
transform: scaleX(0);
transform-origin: left;
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
06Sub-nav dính theo
Sub-nav được đặt sticky với top bằng đúng chiều cao header chính nên nó luôn dừng lại sát mép dưới header thay vì chồng lên, và class .is-stuck chỉ đổi màu viền để báo đã dính chứ không đổi vị trí. Hợp cho tab danh mục trang thương mại điện tử hoặc tab mục lục trang tài liệu.
.subnav {
position: sticky;
top: $head-h;
border-bottom: 4px solid transparent;
transition: border-color $duration $ease-out;
}
.subnav.is-stuck { border-bottom-color: $subject-cream; }
07Tiêu đề section dính theo
Mỗi đoạn trong danh sách có một sentinel riêng; IntersectionObserver theo dõi cả loạt sentinel đó và chỉ bật class .is-current cho nhãn tương ứng với sentinel vừa vào khung nhìn, còn nhãn cũ tự tắt. Hợp cho danh sách chia theo chữ cái như danh bạ hoặc từ điển thuật ngữ, hay một trang FAQ dài.
document.querySelectorAll('.list__sentinel').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) {
labels.forEach(function (l) {
l.classList.toggle('is-current', l.textContent === s.dataset.label);
});
}
}, { rootMargin: '-56px 0px -80% 0px' }).observe(s);
});
08Header backdrop blur
Header giữ nền gần như trong suốt và backdrop-filter: blur(0) khi ở đỉnh trang, rồi bật cả nền đục nhẹ lẫn giá trị blur thật cùng lúc khi sentinel rời khung nhìn, nên nội dung phía sau vẫn hiện ra nhưng nhoè hẳn đi. Hợp cho header đặt trên ảnh hoặc video, hay thanh trên cùng kiểu ứng dụng iOS.
.head {
background: rgba(255, 255, 255, .08);
backdrop-filter: blur(0);
transition: background $duration $easing, backdrop-filter $duration $easing;
}
.head.is-scrolled {
background: rgba(255, 255, 255, .55);
backdrop-filter: blur($blur);
}
09Header đồng bộ scroll-snap
Mỗi section có một sentinel riêng; IntersectionObserver theo dõi section nào đang cắt ngang khung nhìn rồi bật class .is-current cho đúng chấm tương ứng trong header, khớp với đà scroll-snap của trang. Hợp cho trang kể chuyện dạng full-page scroll-snap hoặc phần giới thiệu sản phẩm kiểu slide.
document.querySelectorAll('.snap-section').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) {
dots.forEach(function (d, i) {
d.classList.toggle('is-current', i == s.dataset.i);
});
}
}, { threshold: .6 }).observe(s);
});
Chỗ dễ hỏng ở đâu? — ba cái bẫy
04, 05 và 09 đều dựa vào animation-timeline: scroll(root), và mức hỗ trợ của nó còn chưa đều: theo tài liệu MDN về animation-timeline, Chrome chạy được từ bản 115, Safari từ bản 26, còn Firefox bản ổn định tính đến 9/2026 vẫn cần bật cờ thử nghiệm mới dùng được. Mục 08 backdrop blur cũng đáng nhắc riêng, vì tài liệu MDN về backdrop-filter ghi Chrome hỗ trợ từ bản 76, Safari từ bản 18, Firefox từ bản 103 — ba trình duyệt lớn đều đã qua ngưỡng đó khá lâu nên riêng thuộc tính này an toàn hơn animation-timeline nhiều.
02 và 08 cũng có một bẫy tương tự nhau: cả hai đặt rootMargin của IntersectionObserver bằng một con số cố định, và nếu sau này bạn đổi chiều cao header thì sentinel vẫn phán đoán theo con số cũ nên hiệu ứng bật sai thời điểm — tài liệu MDN về IntersectionObserver khuyên đọc lại getBoundingClientRect().height mỗi khi layout đổi thay vì hardcode một con số. 06 còn thêm một bẫy riêng: top của sub-nav phải khớp đúng chiều cao thật của header chính, và nếu hai giá trị lệch nhau dù chỉ vài pixel thì sub-nav sẽ đè lên một mép header hoặc để lộ một khe hở trắng ngay dưới nó — chín file trong zip đã canh đúng các con số này rồi, và mật khẩu để mở ra xem tận mắt là u9rbcprm.
Bẫy cuối nằm ngay ở lưới xem trước phía trên: khung đó là một iframe cố định 480×300, quá thấp để chứa một cú cuộn thật, nên mỗi ô tự chuyển sang một vòng lặp .is-demo dài hai giây thay cho hành vi cuộn thật của nó. Bạn chỉ đang xem chuyển động lặp lại ở đây; muốn thấy thanh của mục 05 hay chấm của mục 09 phản hồi đúng theo vị trí cuộn thật, hãy mở riêng file vanilla trong tab của bạn rồi tự tay cuộn trang đó.
Khả năng tiếp cận
Cả chín demo đều xử lý prefers-reduced-motion, nhưng cách xử lý khác nhau theo từng nhóm thay vì tắt sạch mọi thứ. 01 bỏ transition và giữ header ở đúng vị trí đang có; 02, 03 và 08 khoá luôn ở trạng thái cuối — bóng đã hiện, màu đã đảo, blur đã bật — vì đó mới là trạng thái người dùng cần thấy. 04 và 06 giữ logo cùng viền sub-nav ở kích thước và màu đích thay vì dừng giữa chừng quá trình biến đổi, còn 05, 07 và 09 tắt animation-timeline nhưng vẫn giữ nguyên chức năng cập nhật qua IntersectionObserver, vì bản thân việc header đổi nội dung theo đúng ngữ cảnh không phải là chuyển động trang trí nên không cần tắt. Tài liệu MDN về prefers-reduced-motion ghi rõ trình duyệt nào đã hỗ trợ media query này.
@media (prefers-reduced-motion: reduce) {
.head, .head__shadow, .head__layer--dark, .head__layer--light,
.head__logo, .head__bar, .subnav, .head__label {
transition: none !important;
animation: none !important;
}
}
Các kiểu header khác đang cuộn theo cách riêng của chúng nằm trong chuyên mục CSS, còn lý do trang này chỉ bán đúng chín kiểu mỗi lần được giải thích ở trang giới thiệu.
FAQ
CSS sticky header có bắt buộc dùng JavaScript không?
Không hoàn toàn. 04 và 05 chỉ cần animation-timeline: scroll(root) để chạy, không có một dòng JS nào điều khiển chuyển động. Bảy mục còn lại dùng vài dòng IntersectionObserver hoặc một listener scroll ngắn để phán đoán hướng, thời điểm hoặc vị trí — mức JS đó rất nhỏ so với một thư viện sticky-header đầy đủ.
position: sticky không hoạt động, vì sao?
Nguyên nhân phổ biến nhất là một phần tử tổ tiên của header đang có overflow: hidden hoặc overflow: auto. Sticky chỉ hoạt động bên trong vùng cuộn của tổ tiên gần nhất có overflow khác visible, nên chỉ cần một div cha bị cắt overflow là header sẽ đứng yên tại chỗ như position: static bình thường.
Header dính đè lên nội dung bên dưới thì sửa thế nào?
Thường là do header không có background đặc hoặc nền trong suốt trong lúc position: sticky vẫn giữ nó nổi trên các phần tử phía sau. Bạn cần đặt sẵn một nền đặc — như 02 và 08 đã làm bằng gradient hoặc backdrop-filter — và kiểm tra z-index của mọi khối cùng cấp để header luôn ở trên cùng chứ không phải chỉ đứng yên.