GODRICH

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

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

translateX(-100%)포커스 트랩inert
.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

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

<dialog>::backdropESC 공짜
.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

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

opacity+scalebackdrop-filter위치 고정
.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

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

3단 스냅peek·half·full손잡이 클릭
.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

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

콘텐츠 동반 이동translateX 쌍오버레이 없음
.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

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

scaleX 확장역스케일 보정hover·focus
.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

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

scroll 방향 감지translateX 은닉내부 콘텐츠 스크롤
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

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

grid-template-rows0fr→1fr쉐브론 회전
.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

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

탭 인디케이터translateX 슬라이드하단 고정
.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

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, marginborder, 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.

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

Các mục 01, 03, 08 khoá nền y hệt nhau khi mở: gắn inertaria-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-hiddentabIndex 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.