CSS 사이드바 드로어 슬라이드 9종 — JS 최소로
CSS 사이드바 드로어는 화면 가장자리에 붙어 있다가 트리거를 누르면 패널이 나타나는 내비게이션 UI로, 여기서는 transform과 opacity만으로 아홉 가지 여는 방식을 만들었습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
순서는 손이 얼마나 많이 들어가는지를 기준으로 잡았습니다. 01은 가장 흔한 슬라이드형이고, 02는 그 일을 브라우저의 dialog 요소에 통째로 맡깁니다. 03~07은 "연다"는 동작 자체가 다르게 생긴 경우(위치를 안 바꾸거나, 콘텐츠를 같이 밀거나, 아예 늘 떠 있거나)이고, 08·09는 계층이 늘어난 경우와 사이드바라는 개념 자체가 모바일에서 다른 UI로 바뀌는 경우입니다.
01왼쪽 슬라이드
패널이 화면 왼쪽 밖에서 시작해 transform: translateX(-100%)에서 translateX(0)으로 이동하며 열리고, 오버레이는 opacity로 함께 어두워집니다. 열리는 순간 배경 콘텐츠는 inert 속성으로 막혀 스크린리더와 Tab 키 모두에서 닿지 않게 됩니다.
.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; }
02dialog 기반 드로어
네이티브 <dialog> 요소를 화면 왼쪽에 고정하고 showModal()로 열면, 포커스 이동과 ESC 닫기, 바깥 클릭 차단을 브라우저가 대신 처리합니다. 실사용 전환에는 @starting-style과 allow-discrete를 써서 열리고 닫히는 순간에도 애니메이션이 걸리게 했습니다.
.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%); }
}
03오버레이 페이드
패널은 왼쪽 위치를 그대로 유지한 채 opacity와 transform: scale만으로 나타나고 사라집니다. 오버레이는 backdrop-filter로 뒤 콘텐츠를 흐리게 만들어 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); }
04바텀시트 스냅
손잡이를 누를 때마다 data-snap 속성이 peek·half·full 세 값을 순환하고, 각 값에 대응하는 translate 규칙 하나만 적용됩니다. 드래그 좌표를 계산하지 않고 클릭만으로 세 단계를 오갑니다.
.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콘텐츠 밀어내기
사이드바와 옆 콘텐츠가 같은 duration 동안 각각 translateX(-100%→0)과 translateX(0→40%)로 동시에 움직여, margin 없이 자리를 나눕니다. 오버레이가 없어서 콘텐츠는 열려 있는 동안에도 계속 상호작용할 수 있습니다.
.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%); }
06미니→확장
레일 전체를 scaleX로 좁혀 두고 안쪽 콘텐츠에는 정확히 역수를 걸어, hover·focus-within으로 넓어질 때 글자가 눌리지 않게 했습니다. JS 없이 CSS만으로 동작합니다.
.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스크롤 시 숨김
옆 콘텐츠 패널의 scroll 이벤트에서 방향을 비교해 레일에 클래스를 토글합니다. 창 전체가 아니라 이 패널의 스크롤만 보기 때문에 문서 뷰어 안에서도 독립적으로 동작합니다.
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;
});
08중첩 메뉴
01과 같은 슬라이드 드로어 안에서 하위 메뉴가 grid-template-rows로 0fr에서 1fr로 펼쳐집니다. 셰브론은 한 요소의 border 코너를 45도에서 225도로 돌려 만들어, 막대 두 개를 따로 돌릴 때 흔한 어긋남 문제를 피합니다.
.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); }
09하단 탭바
탭을 누르면 JS가 인디케이터의 transform을 클릭된 탭의 인덱스로 계산해 translateX(N * 100%)로 옮깁니다. 아이콘은 opacity와 scale로, 라벨은 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); }
어디서 깨지는가 — 함정
가장 자주 겪은 함정은 02의 <dialog>입니다. 브라우저 기본 스타일이 요소를 화면 가운데에 내용 크기만큼만 띄우기 때문에, inset과 margin, border를 전부 지우지 않으면 왼쪽 전체 높이 패널이 아니라 중앙의 작은 상자로 뜹니다. 06도 비슷한 함정이 있는데, 레일을 scaleX로 줄인 뒤 안쪽 콘텐츠에 역수를 안 걸면 확장되기 전 글자가 눌린 채로 보입니다.
두 번째는 07의 스크롤 임계값입니다. 값을 0으로 두면 트랙패드의 미세한 되돌림에도 레일이 깜빡였고, 4px 정도 여유를 둬야 잔떨림이 사라졌습니다. 이런 값이 이미 들어간 아홉 개 파일의 압축 비밀번호는 2g6qnjwn이고, 04의 스냅 값도 시트 자기 높이를 기준으로 한 퍼센트라 내용 길이가 바뀌면 같이 움직입니다.
접근성
01·08처럼 배경을 덮는 드로어는 열릴 때 배경 콘텐츠에 inert와 aria-hidden을 걸고, 첫 포커스 가능 요소로 옮긴 뒤 Tab 키가 드로어 밖으로 못 나가게 가둡니다. 02는 이 일을 전부 <dialog>가 대신 하므로 별도 코드가 없습니다. prefers-reduced-motion: reduce에서는 아홉 개 전부 진입·퇴장 애니메이션을 지우고 최종 상태로 즉시 전환하되, 04의 스냅 전환처럼 상태 자체가 바뀌는 동작은 남겨 둡니다. 05는 모달이 아니라 상시 내비게이션이라 inert 대신 링크의 aria-hidden과 tabIndex를 열림 여부에 맞춰 바꿉니다. reduce 설정을 브라우저가 어떻게 읽는지는 MDN 문서에 정리되어 있습니다.
<dialog> 요소 자체의 접근성은 MDN dialog 문서를 참고하면 되고, 02가 쓰는 @starting-style 전환의 브라우저 지원은 쓰기 전에 caniuse로 확인하세요. 아이콘·트리거의 여닫힘 방식은 햄버거 메뉴 9종과 겹치지 않게 패널 이동 방식에만 집중했고, 다른 CSS 애니메이션 주제는 CSS 애니메이션 카테고리에, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
사이드바와 드로어는 같은 건가요?
대체로 같습니다. 사이드바는 화면 가장자리에 붙는 패널을 가리키는 넓은 말이고, 드로어는 그 패널이 서랍처럼 밀려 나오는 움직임을 가리킵니다. 이 글의 아홉 개는 전부 사이드바이면서 여는 방식만 다릅니다.
React에서는 상태를 어떻게 관리하나요?
zip의 react 폴더는 각 항목을 useState 하나로 여닫습니다. 04는 peek·half·full 세 단계를 문자열 상태로, 09는 활성 탭 인덱스를 숫자 상태로 둡니다. 나머지는 boolean 하나로 충분합니다.
모바일에서는 어떤 걸 써야 하나요?
화면이 좁으면 사이드바 대신 09 하단 탭바가 더 자연스럽습니다. 꼭 사이드바가 필요하다면 04 바텀시트나 01 왼쪽 슬라이드가 손가락 조작에 적합합니다.