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

9 mẫu css sticky header — copy-paste là chạy

Css sticky header là cách giữ hàng menu trên cùng bám mãi trên màn hình dù cuộn xuống đâu, chỉ một dòng position: sticky là đủ.

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

Chín mục không xếp theo độ nổi tiếng mà gộp theo thứ hạng vật nào đổi bên trong header. Nhóm tiết kiệm không gian khi thứ gì đó gập lại·thu hẹp (01·02·07), nhóm thông tin dẫn dắt dịch chuyển·lộ ra theo vị trí cuộn (03·06), nhóm UI tìm kiếm giãn rộng·hợp nhất với hàng khác (04·05), và nhóm phần tử vốn không có trên màn hình bất ngờ xuất hiện (08·09). Khác với css table header chỉ ghim hàng đầu của bảng, bài này xử lý trọn hàng header ở đỉnh trang.

01Thanh thông báo gập lại

Banner trượt lên và biến mất

Hàng banner khuyến mãi trên cùng trượt lên thoáng cái ngay khi bạn bắt đầu cuộn, không xô lệch bố cục, và hàng logo dâng lên chiếm chỗ trống.

Thanh thông báo thu gọn
Mở trong tab mới

01 · Thanh thông báo thu gọn — Diện tích đo được 23.972% · 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

translateY(-100%)2단→1단scroll 임계값

Vì sao bố cục không nhấp nhô

Banner và header chồng lên cùng một slot bằng vị trí tuyệt đối rồi chỉ có transform chuyển động, nên trong lúc banner biến mất bố cục không hề rung chuyển.

.ahc__bar {
  position: absolute; inset: 0 0 auto 0;
  height: 32px;
  transform: translateY(0);
  opacity: 1;
  transition: transform .3s ease-out, opacity .3s ease-out;
}
.ahc__head {
  position: absolute; left: 0; right: 0; top: 32px;
  height: 56px;
  transform: translateY(0);
  transition: transform .3s ease-out;
}

// khi dùng thật: bắt đầu cuộn là banner trượt lên, header dâng lên thế chỗ
.ahc.is-scrolled {
  .ahc__bar { transform: translateY(-100%); opacity: 0; }
  .ahc__head { transform: translateY(-32px); }
}

Easing khi thu gọn không phải ease-out mà là đường cong lerp quán tính: các bước p += (mục tiêu - p) * alpha lặp lại được nướng vào hàm linear() của CSS, nhờ đó header vẫn trôi theo thêm một chút sau khi cuộn dừng.

02Chỉ còn icon trong nav

Nhãn chữ biến mất

Nhãn chữ của các liên kết điều hướng thu hết bề rộng khi cuộn và chỉ còn icon, tiết kiệm chiều ngang header đang hẹp dần.

Nav chỉ còn icon
Mở trong tab mới

02 · Nav chỉ còn icon — Diện tích đo được 1.844% · 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

width:0 축소opacity 페이드clip 텍스트

Phải hạ max-width theo

Nếu chỉ đổi opacity thì khoảng trống vẫn còn, nên phải đưa max-width về 0 nữa thì các icon mới thật sự sát về bên trái.

.ioc__label {
  overflow: hidden;
  white-space: nowrap;
  max-width: 40px;
  transform-origin: left center;
  transition: max-width .3s ease-out, transform .3s ease-out, opacity .3s ease-out;
}

// khi dùng thật: cuộn xuống là nhãn gập theo bề rộng, chỉ còn icon
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }

03Gạch chân tab đồng bộ theo cuộn

Trong header cố định, gạch chân bám theo tab

Dưới các tab neo nằm cạnh nhau trong header, một gạch chân trượt sang tab kế tiếp sao cho khớp với khu vực đang đi qua.

Gạch chân tab theo cuộn
Mở trong tab mới

03 · Gạch chân tab theo cuộn — Diện tích đo được 0.308% · 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

translateX 고정폭@keyframes tus-loopflex:0 0 auto

Cách tìm khu vực hiện tại

Trên trang thật, IntersectionObserver xác định khu vực đang được đi qua rồi đưa gạch chân đến vị trí tab tương ứng.

// độ rộng các tab khác nhau nên đọc offsetLeft·offsetWidth lúc chạy — cấm px phỏng đoán
var nav = document.getElementById('tusNav');
var tabs = nav.querySelectorAll('.tus__tab');
var underline = nav.querySelector('.tus__underline');
function moveTo(tab) {
  var w = underline.offsetWidth;
  underline.style.transform =
    'translateX(' + (tab.offsetLeft + tab.offsetWidth / 2 - w / 2) + 'px)';
}

04Hàng tìm kiếm nhập vào header

Hàng tìm kiếm trượt lên hòa vào header

Hàng nhập tìm kiếm vốn nằm riêng dưới header trượt lên khi cuộn và hòa vào đúng một hàng của header chính.

Thanh search nhập vào header
Mở trong tab mới

04 · Thanh search nhập vào header — Diện tích đo được 15.596% · 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

2행→1행translateY 합치기높이 트랜지션

Chồng lớp theo chiều ngược của 01

Hai hàng được xếp chồng trong slot chiều cao cố định rồi chỉ hàng tìm kiếm được kéo lên bằng translateY đúng bằng chiều cao header — cùng cấu trúc chồng lớp với 01 nhưng theo chiều ngược lại.

.sbm__search {
  position: absolute; left: 0; right: 0; top: 56px;
  height: 40px;
  border-top: 1px solid rgba($stage-ink, .08);
  transform: translateY(0);
  transition: transform .3s ease-out;
}

// khi dùng thật: bắt đầu cuộn là hàng tìm kiếm trượt lên hòa vào hàng header
.sbm.is-scrolled .sbm__search { transform: translateY(-56px); }

05Ô tìm kiếm giãn rộng

Icon giãn thành ô nhập

Chỗ vốn chỉ có một icon kính lúp giãn bề rộng khi nhấp và hóa thành ô nhập chữ thật.

Icon search bung thành ô nhập
Mở trong tab mới

05 · Icon search bung thành ô nhập — Diện tích đo được 3.736% · 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

transition: width아이콘→입력창포커스 트리거

Duy nhất không dính gì đến cuộn

Đây là mục duy nhất trong chín cái không liên quan scroll; cái kích hoạt là nhấp (focus), không phải vị trí cuộn.

.sse__search {
  width: 36px;
  border-radius: $r-pill;
  background: rgba($subject-cream, .12);
  overflow: hidden;
  transition: width .3s ease-spring, background .3s ease-spring;

  &.is-open { width: 156px; background: $subject-cream; }
}

06Đường dẫn xuất hiện khi cuộn

Vị trí hiện lên tại chỗ trống

Khoảng trống cạnh logo, đúng khoảnh khắc bạn cuộn, lộ ra đường dẫn vị trí đang xem bằng hiệu ứng fade kèm trượt.

Breadcrumb hiện khi cuộn
Mở trong tab mới

06 · Breadcrumb hiện khi cuộn — Diện tích đo được 13.451% · 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

opacity+translateX페이지 위치 표시순수 CSS

Chuyển động giao hết cho CSS

JavaScript chỉ gắn đúng một lớp; còn mọi chuyển động (opacity·translateX) do transition của CSS gánh trọn.

.head__crumb {
  display: flex; align-items: center; gap: $sp-2;
  opacity: 0;
  transform: translateX(-16px);
  transition: opacity .2s ease-spring, transform .2s ease-spring;
}

// khi dùng thật: JS chỉ gắn lớp, fade+trượt hiện breadcrumb là transition CSS thuần
.head.is-scrolled .head__crumb { opacity: 1; transform: translateX(0); }

07Menu người dùng nén lại

Tên gập lại, chỉ còn avatar

Dòng tên người dùng nép cạnh avatar bên phải thu hết bề rộng khi cuộn và để lại đúng icon avatar.

Menu người dùng thu gọn
Mở trong tab mới

07 · Menu người dùng thu gọn — Diện tích đo được 0.298% · 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

max-width 축소텍스트 clip아바타만 유지

Hạ cả margin-right

Đây là dạng áp kỹ thuật gập max-width của mục 02 cho phần tử canh phải, nên phải đưa margin-right về 0 theo thì avatar mới thật sự sát mép phải.

.head__name {
  display: inline-block;
  max-width: 104px;
  margin-right: $sp-2;
  overflow: hidden;
  white-space: nowrap;
  transform: scaleX(1);
  transform-origin: left;
  transition: max-width .3s ease-out, margin-right .3s ease-out,
              opacity .3s ease-out, transform .3s ease-out;
}

.head.is-scrolled .head__name {
  max-width: 0; margin-right: 0; opacity: 0; transform: scaleX(0);
}

08Nút CTA trượt vào

Phải cuộn thật sâu nút mới vào

Chỉ khi hạ qua một đoạn nhất định của trang, nút mua·liên hệ mới trượt từ ngoài màn hình vào chiếm chỗ ở mép phải header.

Nút CTA trượt vào
Mở trong tab mới

08 · Nút CTA trượt vào — Diện tích đo được 14.556% · 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

translateX(100%)→0스크롤 임계값transform만

Vì sao ngưỡng lớn nhất

Trong chín mục, cái này có ngưỡng cuộn lớn nhất (120px) nên trang chẳng vội quăng nút vào mặt bạn ngay từ phút đầu — khác hẳn kiểu css sticky header on scroll đổ nút ra ngay khi bạn vừa nhích chuột.

.head__cta {
  transform: translateX(140%);
  transition: transform .3s ease-spring;
}

// khi dùng thật: cuộn quá 120px nút mới trượt từ ngoài màn hình vào
.head.is-scrolled .head__cta { transform: translateX(0); }

09Chấm thông báo xuất hiện

Chấm bật ra rồi đập theo nhịp

Ngay lúc cuộn vượt 80px, chấm thông báo trên icon trong header phóng to như bật ra, sau đó đập nhẹ theo nhịp mãi.

Chấm thông báo bật lên
Mở trong tab mới

09 · Chấm thông báo bật lên — Diện tích đo được 0.449% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scale pop반복 pulsescroll 임계값

Vì sao tách keyframes làm đôi

Điểm đặc biệt là keyframes cho khoảnh khắc xuất hiện và keyframes cho nhịp đập lặp mãi sau đó được tách riêng hai bộ.

.head__dot {
  transform: scale(0);
  opacity: 0;
  transition: transform .15s ease-pop, opacity .15s ease-pop;
}

.head.is-scrolled .head__dot {
  transform: scale(1);
  opacity: 1;
  animation: dot-pulse-real 1.6s ease-in-out infinite;
}
@keyframes dot-pulse-real {
  0%, 100% { transform: scale(1); opacity: 1; }
  50%      { transform: scale(1.3); opacity: .7; }
}

Header cố định css dễ hỏng ở đâu

TranslateX phỏng đoán luôn sai

Vướng nhiều nhất là kiểu indicator trượt như mục 03. Một phần tử lượn giữa các tab kiểu gạch chân hay pill, khi độ rộng tab chênh nhau (trong demo này "Tổng quan" 61.45px·"Báo cáo" 76.17px·"Cài đặt" 61.45px), rất dễ khiến bạn nhét giá trị translateX theo phỏng đoán; mà hễ độ rộng không đều thì phỏng đoán ấy luôn sai. Mục này cố chiều rộng tab theo nội dung bằng flex: 0 0 auto rồi đọc offsetLeft·offsetWidth thật để tính vị trí gạch chân (18.73px·87.54px·156.35px). Muốn tự tái hiện cái sai đó bằng CSS thuần, cứ cấp cho tab flex: 1 1 0 (chia đều) rồi di chuyển theo chiều rộng cố định: gạch chân sẽ thiếu trên tab ngắn và tràn trên tab dài — gốc rễ của chứng css sticky header overlapping content nằm ở đây.

Thứ tự chồng nhau và mục 05

Mở thẳng thư mục mục 03 và đổi số chữ trong tab, bạn sẽ thấy gạch chân tính lại theo số đo thật ngay lập tức. Thứ hai cần để ý là mục 05 (ô tìm kiếm giãn rộng): nhìn tên thì thuộc bộ sticky header, nhưng thật ra nó chỉ mở bằng nhấp, hoàn toàn không ăn theo scroll — ai vào đọc code với kỳ vọng gặp một scroll listener sẽ hoảng vì chẳng thấy đâu. Cấu trúc slot hai tầng chồng như mục 04 cũng chỉ sai một nấc thứ tự (kẹt z-index cho lớp tuyệt đối) là hàng tìm kiếm trèo lên trên logo ngay, nên phải rà kỹ thứ tự chồng. Zip chứa chín file này có mật khẩu weqydbxa, giải nén ra sẽ thấy đúng cấu trúc vanilla/·react/ để mở đối chiếu trực tiếp.

Khả năng tiếp cận — chín mục làm gì dưới reduced-motion

Chỉ bỏ thời lượng chuyển tiếp

Với người bật prefers-reduced-motion: reduce, chín mục phản ứng khác nhau chút ít. Các mục 01·02·04·06·07·08 dùng transition: none để xóa đúng thời lượng chuyển tiếp: trên trang thật, khi vượt ngưỡng cuộn thì banner gập, nhãn thu, tên biến mất vẫn diễn ra — chỉ là đổi tức thì, giữ chức năng mà bỏ phần animation.

Riêng mục 09 xử khác

Chỉ mục 09 (chấm thông báo) xử khác: tắt hẳn nhịp dot-pulse-real lặp vô hạn sau khi xuất hiện chứ không chỉ tắt chuyển tiếp, vì animation lặp vô hạn tự nó là chuyển động nên phải tắt riêng — chấm hiện lên một lần rồi đứng hẳn. Mục 03 (gạch chân tab) và 05 (ô tìm kiếm) vốn không tự phản ứng theo cuộn, nên "chuyển tiếp" cần thu ngắn chỉ xảy ra lúc người dùng nhấp. Thêm nữa, position: sticky còn dính luật vùng chứa cuộn mà tài liệu MDN về position ghi rõ, còn mục 03 dùng MDN IntersectionObserver để phát hiện viewport.

Đọc thêm

Muốn xem dạng header tự gập·đổ bóng khi cuộn thì đọc bài 9 kiểu css sticky header. Chủ đề cuộn nói chung gom trong chuyên mục CSS animation, còn toàn bộ anime phản ứng theo cuộn nằm ở 9 kiểu scroll animation.

FAQ — hỏi đáp nhanh

Hiệu ứng cố định thanh điều hướng có phải mục nào cũng hiện sau khi cuộn không?

Không. Bảy mục (01·02·04·06·07·08·09) phản ứng theo vị trí cuộn (ngưỡng 20~120px), mục 03 theo khu vực nào đang hiện, riêng mục 05 (ô tìm kiếm giãn rộng) chỉ mở bằng nhấp, chẳng dính đến cuộn.

Khi header chồng nhiều tầng (banner+header, header+tìm kiếm) vì sao dùng transform thay height?

Chuyển động bằng height hay margin buộc trình duyệt tính lại bố cục mỗi khung hình nên rất dễ giật. Thay vào đó xếp hai tầng chồng bằng vị trí tuyệt đối rồi chỉ di transform: translateY() là đổi riêng lớp hợp thành, không đụng bố cục nên mượt hơn. Mục 01·04 theo đúng cách này.

Code vanilla và code react khác nhau không?

Phần chuyển động như nhau. Khác mỗi cái kích hoạt: demo vanilla chạy lặp vô hạn bằng lớp is-demo, còn React component nghe trực tiếp sự kiện scroll thật (mục 03 là IntersectionObserver) rồi đổi lớp khi đúng điều kiệ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.