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

9 hiệu ứng CSS Sidebar Animation - JS tối thiểu

CSS sidebar animation là cách một drawer đóng mở bằng transform và opacity, còn JavaScript chỉ lo phần bắt buộc: bật trạng thái, khoá nền, focus trap và phím

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

Thứ tự xếp theo lượng JS cần thêm vào. 01 là kiểu slide quen thuộc nhất, phải tự viết tay gần như toàn bộ phần khoá nền. 02 đẩy hẳn việc đó cho thẻ dialog gốc của trình duyệt. 03 đến 07 đổi hẳn cách "mở" nhìn ra sao — đứng yên mà mờ dần, snap theo ba mốc, kéo nội dung đi cùng, hoặc luôn hiện rồi tự ẩn theo scroll. 08 và 09 là hai trường hợp cấu trúc khác hẳn: một sidebar có thêm tầng con, và một sidebar được thay hẳn bằng kiểu UI khác — hợp với màn hình hẹp hơn.

01Trượt từ trái

Bạn thấy gì

Panel bắt đầu ngoài mép trái màn hình rồi trượt vào nhờ transform đổi từ translateX(-100%) sang translateX(0), overlay tối dần theo opacity cùng lúc.

Trượt từ trái
Mở trong tab mới

01 · Trượt từ trái — Diện tích đo được 48.566% · 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

translateX(-100%)포커스 트랩inert

Dùng ở đâu

Ngay khi mở, nội dung nền bị khoá bằng thuộc tính inert nên cả trình đọc màn hình lẫn phím Tab đều không chạm tới được.

.drawer {
  position: fixed; left: 0; top: 0; bottom: 0;
  transform: translateX(-100%);
  width: min(76vw, 240px);
  transition: transform $duration $easing;
}
.overlay {
  position: fixed; inset: 0;
  opacity: 0; pointer-events: none;
  transition: opacity $duration $easing;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .drawer  { transform: translateX(0); pointer-events: auto; }

02Drawer dùng dialog gốc

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

Thẻ <dialog> gốc được ghim sát bên trái và mở bằng đúng một dòng showModal(), nên focus, phím Escape và việc chặn click ra ngoài đều do trình duyệt lo thay vì tự viết.

Drawer dùng dialog gốc
Mở trong tab mới

02 · Drawer dùng dialog gốc — Diện tích đo được 98.965% · 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

<dialog>::backdropESC 공짜

Chỗ dùng

Bản chuyển động thật dùng @starting-style cùng allow-discrete để transition vẫn chạy trong lúc dialog vừa bật display.

.drawer {
  position: fixed; inset: 0 auto 0 0; margin: 0;
  width: min(76vw, 240px); height: 100%;
  transition: transform $duration $easing,
    overlay $duration allow-discrete, display $duration allow-discrete;
  transform: translateX(-100%);
}
.drawer[open] { transform: translateX(0); }
@starting-style {
  .drawer[open] { transform: translateX(-100%); }
}

03Overlay mờ dần

Chuyển động thế nào

Panel đứng nguyên bên trái, chỉ hiện ra bằng opacity cùng transform: scale từ .96 lên 1 — không hề có chuyển động ngang.

Overlay mờ dần
Mở trong tab mới

03 · Overlay mờ dần — Diện tích đo được 100.0% · 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

opacity+scalebackdrop-filter위치 고정

Dùng khi nào

Overlay phía sau dùng backdrop-filter: blur() để làm mờ nội dung, khác hẳn lớp phủ màu đặc của mục 01.

.overlay {
  opacity: 0; backdrop-filter: blur(0px);
  transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
.drawer {
  transform: scale(.96); transform-origin: left center; opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(6px); }
.stage.is-open .drawer  { opacity: 1; transform: scale(1); }

04Bottom sheet snap 3 mức

Bạn thấy gì

Mỗi lần chạm tay cầm, thuộc tính data-snap đổi vòng qua ba giá trị peek, half, full, và mỗi giá trị chỉ khớp đúng một rule translate cố định.

Bottom sheet snap 3 mức
Mở trong tab mới

04 · Bottom sheet snap 3 mức — Diện tích đo được 97.949% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

3단 스냅peek·half·full손잡이 클릭

Dùng ở đâu

Không có phép tính toạ độ kéo thả nào cả, chỉ là đổi trạng thái rồi CSS tự snap tới điểm đã định sẵn.

.sheet {
  transform: translate(-50%, 100%);
  transition: transform $duration $easing;
}
.stage.is-open .sheet[data-snap="peek"] { transform: translate(-50%, 78%); }
.stage.is-open .sheet[data-snap="half"] { transform: translate(-50%, 40%); }
.stage.is-open .sheet[data-snap="full"] { transform: translate(-50%, 0); }

05Đẩy nội dung

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

Sidebar và nội dung bên cạnh chạy trong cùng một duration: sidebar đi từ translateX(-100%) về 0, còn nội dung đi từ translateX(0) sang 40%, không đụng tới margin.

Đẩy nội dung
Mở trong tab mới

05 · Đẩy nội dung — Diện tích đo được 15.844% · 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

콘텐츠 동반 이동translateX 쌍오버레이 없음

Chỗ dùng

Vì không có overlay nên nội dung vẫn bấm được bình thường trong lúc sidebar đang mở.

.drawer {
  transform: translateX(-100%);
  transition: transform $duration $easing;
}
.content {
  transform: translateX(0);
  transition: transform $duration $easing;
}
.stage.is-open .drawer  { transform: translateX(0); }
.stage.is-open .content { transform: translateX(40%); }

06Mini mở rộng

Chuyển động thế nào

Rail thu nhỏ nguyên khối bằng scaleX(56/200), còn nội dung bên trong dùng đúng số nghịch đảo để chữ và icon không bị bóp méo lúc còn hẹp.

Mini mở rộng
Mở trong tab mới

06 · Mini mở rộng — Diện tích đo được 16.075% · 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

scaleX 확장역스케일 보정hover·focus

Dùng khi nào

Khi hover hoặc focus-within, cả hai cùng trả về scaleX(1) — width chưa từng được dùng tới.

.rail {
  transform: scaleX(.28);
  transform-origin: left center;
  transition: transform $duration $easing;
  &:hover, &:focus-within { transform: scaleX(1); }
}
.rail__inner {
  transform: scaleX(3.57);
  transform-origin: left center;
  .rail:hover &, .rail:focus-within & { transform: scaleX(1); }
}

07Ẩn khi cuộn

Bạn thấy gì

Sự kiện scroll được gắn trên chính panel nội dung bên cạnh, không phải trên cửa sổ, so sánh scrollTop hiện tại với lần trước để bật hoặc gỡ class is-hidden trên rail.

Ẩn khi cuộn
Mở trong tab mới

07 · Ẩn khi cuộn — Diện tích đo được 9.937% · 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

scroll 방향 감지translateX 은닉내부 콘텐츠 스크롤

Dùng ở đâu

Ngưỡng lệch 4px giúp bỏ qua rung nhẹ của trackpad khi cuộn gần như đứng yên.

content.addEventListener('scroll', function () {
  var y = content.scrollTop;
  if (y > last + 4) rail.classList.add('is-hidden');
  else if (y < last - 4) rail.classList.remove('is-hidden');
  last = y;
});

08Menu lồng nhau

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

Sidebar mở theo đúng kiểu trượt của mục 01, còn submenu bên trong xoè ra bằng grid-template-rows từ 0fr sang 1fr.

Menu lồng nhau
Mở trong tab mới

08 · Menu lồng nhau — Diện tích đo được 100.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

grid-template-rows0fr→1fr쉐브론 회전

Chỗ dùng

Chevron không phải một ký tự mũi tên mà là góc của một khối border được xoay từ 45deg sang 225deg khi submenu mở.

.submenu {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $dur-base $ease-out;
}
.group.is-open .submenu { grid-template-rows: 1fr; }
.chevron {
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.group.is-open .chevron { transform: rotate(225deg); }

09Thanh tab dưới cùng

Chuyển động thế nào

Chạm vào một tab, JS tính vị trí theo chỉ số rồi gán thẳng indicator.style.transform = translateX(i * 100%), CSS chỉ khai báo sẵn transition cho thuộc tính transform.

Thanh tab dưới cùng
Mở trong tab mới

09 · Thanh tab dưới cùng — Diện tích đo được 1.886% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

탭 인디케이터translateX 슬라이드하단 고정

Dùng khi nào

Icon của tab vừa chọn phóng to nhẹ qua scale, còn nhãn dịch lên nhẹ qua translateY, cả hai cùng rõ hơn qua opacity.

.indicator {
  position: absolute; left: $sp-1; bottom: $sp-1;
  width: calc(25% - #{$sp-1});
  transform: translateX(0);
  transition: transform $duration $easing;
}
.tab.is-active .tab__ic { opacity: 1; transform: scale(1.15); }

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

Style mặc định của dialog

Chỗ dễ vỡ nhất là style mặc định của <dialog> ở mục 02. Trình duyệt tự căn nó vào giữa màn hình theo đúng kích thước nội dung, nên nếu không xoá hết inset, margin và border, thứ hiện ra không phải panel cao hết màn hình bên trái mà là một hộp nhỏ nằm giữa. Mục 06 vấp lỗi tương tự theo hướng khác — quên đặt scaleX nghịch đảo cho .rail__inner thì chữ bên trong sẽ bị bóp dẹt trước khi rail kịp mở rộng.

Ngưỡng scroll và iframe

Chỗ thứ hai từng gặp là ngưỡng scroll ở mục 07: để giá trị 0 thì mỗi rung nhẹ của trackpad cũng làm rail nhấp nháy, phải nâng lên 4px mới hết. Bộ chín file đã chỉnh sẵn hai lỗi đó nằm trong file nén, mở bằng mật khẩu 2g6qnjwn, gõ đúng như nó hiện trên dòng này. Một chỗ cần cẩn thận khác không nằm trong CSS: vì demo này được nhúng qua iframe ngay trên trang chủ, mọi lệnh .focus() trong JS thật đều phải kèm { preventScroll: true } — thiếu cờ đó thì việc mở drawer sẽ kéo luôn scroll của trang cha.

Khả năng tiếp cận

Khoá nền và bẫy focus

Các mục 01, 03, 08 khoá nền y hệt nhau khi mở: gắn inert và aria-hidden="true" lên vùng nội dung phía sau, đưa focus tới phần tử có thể focus đầu tiên trong drawer, rồi giữ phím Tab xoay vòng bên trong bằng cách theo dõi phần tử đầu và cuối. Đoạn JS thật trong cả ba mục gần như giống hệt nhau:

function open() {
  page.setAttribute('inert', '');
  page.setAttribute('aria-hidden', 'true');
  focusables()[0].focus({ preventScroll: true });
}
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape') { shut(); return; }
  if (e.key !== 'Tab') return;
  var f = focusables(), first = f[0], last = f[f.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    last.focus({ preventScroll: true }); e.preventDefault();
  } else if (!e.shiftKey && document.activeElement === last) {
    first.focus({ preventScroll: true }); e.preventDefault();
  }
});

Mục 02 không cần đoạn code này vì <dialog> cùng showModal() đã tự làm mọi việc — focus trap, Escape, chặn tương tác nền đều là hành vi mặc định của trình duyệt, MDN xác nhận rõ điều này. Mục 05 không phải modal mà là điều hướng luôn hiện diện, nên thay vì inert nó chỉ đổi aria-hidden và tabIndex của các link theo trạng thái đóng mở, còn mục 09 để mỗi tab tự mang aria-current="page" khi đang chọn. Thuộc tính inert được trình duyệt hiện đại hỗ trợ rộng, còn cách prefers-reduced-motion: reduce tắt animation ở cả chín mục được ghi rõ trong trang MDN về reduced-motion.

Các hiệu ứng CSS khác của site nằm trong chuyên mục CSS, còn trang giới thiệu nói rõ vì sao mỗi bài ở đây đều đóng gói kèm bản đo đạc thật.

FAQ

Sidebar và drawer có phải cùng một thứ không?

Gần như vậy. Sidebar là tên chung cho một panel dính vào mép màn hình, còn drawer chỉ cách nó xuất hiện — kiểu trượt ra như ngăn kéo. Chín mục trong bài này đều là sidebar, chỉ khác nhau ở cách mở.

Bản React trong file nén quản lý trạng thái kiểu gì?

Phần lớn chỉ cần một useState boolean để đóng mở. Riêng mục 04 giữ ba mốc peek, half, full bằng một state kiểu chuỗi, còn mục 09 giữ chỉ số tab đang chọn bằng một state kiểu số.

Trên di động nên chọn kiểu nào?

Nếu màn hình hẹp, mục 09 thanh tab dưới cùng tự nhiên hơn hẳn một sidebar thu nhỏ. Còn khi vẫn cần đúng nghĩa sidebar, mục 04 bottom sheet hoặc mục 01 trượt từ trái là hai kiểu dễ thao tác bằng ngón tay nhất.

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.