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

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

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

Bạn thấy gì

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

Ẩn rồi hiện lại
Mở trong tab mới

01 · Ẩn rồi hiện lại — Diện tích đo được 3.501% · 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

position:stickytranslateY(-100%)방향 감지

Dùng ở đâu

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

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

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.

Đổ bóng khi cuộn
Mở trong tab mới

02 · Đổ bóng khi cuộn — Diện tích đo được 15.0% · khung hình chuyển động 13/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

IntersectionObserver그림자 레이어 opacity0→1

Chỗ dùng

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

Chuyển động thế nào

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.

Đảo màu khi cuộn
Mở trong tab mới

03 · Đảo màu khi cuộn — Diện tích đo được 18.667% · khung hình chuyển động 13/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

IntersectionObserver색 토큰 교체히어로 높이 기준

Dùng khi nào

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ỏ

Bạn thấy gì

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.

Chỉ logo thu nhỏ
Mở trong tab mới

04 · Chỉ logo thu nhỏ — Diện tích đo được 1.585% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

transform:scale높이 고정transform-origin:left

Dùng ở đâu

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

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

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.

Gắn thanh tiến độ
Mở trong tab mới

05 · Gắn thanh tiến độ — Diện tích đo được 1.0% · khung hình chuyển động 22/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scroll() 타임라인하단 3px헤더 자식 요소

Chỗ dùng

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

Chuyển động thế nào

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

Sub-nav dính theo
Mở trong tab mới

06 · Sub-nav dính theo — Diện tích đo được 1.333% · khung hình chuyển động 14/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

top:헤더높이이중 sticky겹침 없음

Dùng khi nào

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

Bạn thấy gì

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.

Tiêu đề section dính theo
Mở trong tab mới

07 · Tiêu đề section dính theo — Diện tích đo được 0.241% · khung hình chuyển động 12/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

IntersectionObserver센티넬 요소제목 교체

Dùng ở đâu

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

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

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.

Header backdrop blur
Mở trong tab mới

08 · Header backdrop blur — Diện tích đo được 18.283% · 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

backdrop-filter:blur반투명 배경IntersectionObserver

Chỗ dùng

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

Chuyển động thế nào

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.

Header đồng bộ scroll-snap
Mở trong tab mới

09 · Header đồng bộ scroll-snap — Diện tích đo được 0.281% · 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

scroll-snap-typeIntersectionObserver인디케이터 동기화

Dùng khi nào

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

animation-timeline scroll root và mức hỗ trợ

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.

rootMargin cố định của IntersectionObserver

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.

Lưới xem trước 480×300

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 xử lý theo từng nhóm demo

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.

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.