개요
탐색 창은 표시 영역의 가장자리에서 페이지 콘텐츠 위로 슬라이드되어 뒤에 있는 모든 항목을 어둡게 처리하는 패널입니다. 트리거 버튼에서 열리며, 패널을 화면 밖으로 스와이프하거나, 어두운 배경을 탭하거나, Esc 키를 눌러 닫을 수 있습니다.
이 가이드에서는 창을 다음과 같이 구현합니다.
popover="manual"요소가 최상위 레이어로 승격되어 패널과::backdrop가 쌓기 컨텍스트와 관계없이 페이지의 다른 모든 요소를 오버레이합니다.- '열기'와 '닫기'라는 두 개의 CSS 스크롤 스냅 중지가 있는 가로 스크롤 컨테이너로, 스와이프 동작이 브라우저에서 기본적으로 처리됩니다. 이를 통해 JavaScript 포인터 이벤트 코드 없이도 모멘텀, 속도, 중단 추적을 무료로 제공합니다.
- 배경의 불투명도를 스크롤 위치에 연결하는 스크롤 기반 애니메이션으로, 사용자가 패널을 드래그할 때 어두운 부분이 부드럽게 페이드 인 및 페이드 아웃됩니다.
- 패널의
IntersectionObserver는 표시 영역에 완전히 들어오거나 완전히 나가는 시점을 감지하고, 이 시점을 사용하여 포커스,aria-expanded,inert를 업데이트합니다.
이 접근 방식은 JavaScript 기반 transform 애니메이션보다 선호됩니다. 스크롤 메커니즘을 통해 사용자가 패널의 위치를 직접 제어할 수 있고 (트윈이 아닌 손가락으로 제어) 기본 모바일 앱에서 사용자가 익숙한 상호작용 패턴과 훨씬 더 가깝기 때문입니다.
구현
1. 마크업
드로어는 표시되는 '시트'가 포함된 가로 스크롤러가 포함된 단일 팝오버입니다. 트리거 버튼은 페이지 콘텐츠에 있습니다.
<!-- popover="manual" is REQUIRED. Do not use popover="auto" or "hint". -->
<div class="Drawer" id="drawer" popover="manual">
<div class="Drawer-scroller">
<nav class="Drawer-sheet" tabindex="-1">
<!-- tabindex="-1" makes the sheet programmatically focusable so we
can move focus into it when the drawer opens, without adding it
to the natural tab order. -->
<ul>
<li><a href="/page-1">Page 1</a></li>
<li><a href="/page-2">Page 2</a></li>
<!-- ... -->
</ul>
</nav>
</div>
</div>
<main>
<header>
<!-- aria-controls links the trigger to the drawer; aria-expanded
reflects the current state for assistive tech. -->
<button id="drawer-open"
aria-label="Menu"
aria-expanded="false"
aria-controls="drawer">
<!-- MANDATORY: Inline decorative SVGs MUST define aria-hidden="true" -->
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>
</header>
<!-- Page content. -->
</main>
2. 스타일
팝오버 재설정 및 표시 영역 채우기
팝오버는 전체 표시 영역을 덮어야 하므로 ::backdrop가 전체 페이지를 어둡게 하고 스와이프 표시 영역이 가장자리에서 가장자리까지 확장됩니다. 기본 사용자 에이전트 팝오버 스타일 (가운데 정렬, 자동 크기 조정, 테두리)이 방해가 되므로 재설정해야 합니다.
.Drawer {
/* min() caps the sheet width on large screens but on a phone leaves
a 20% peek of page content visible, which is the affordance that
tells the user they can tap outside to dismiss. */
--drawer-width: min(20em, 80dvw);
/* Custom property driven by the scroll-driven animation below.
0 = drawer fully closed (transparent backdrop).
1 = drawer fully open (visible backdrop). */
--drawer-backdrop: 0;
/* Reset UA popover style that would constrain the element. */
width: auto;
height: auto;
background: transparent;
border: 0;
overflow: visible;
}
/* Style the popover's ::backdrop to achieve the overlay effect and
provide visual affordances indicating that the rest of the page is inert */
.Drawer::backdrop {
background: #000;
/* Use calc() to limit the opacity range so the content beneath is visible */
opacity: calc(var(--drawer-backdrop) / 2);
}
스크롤 스냅으로 스와이프 표시 경로 빌드
스크롤러는 표시 영역보다 넓은 가로 그리드입니다. 열 1은 시트 (너비 --drawer-width)를 보유하고 열 2는 표시 영역 너비의 빈 가상 요소 스페이서입니다. 두 열 사이를 스냅하면 창이 열리고 닫힙니다.
.Drawer-scroller {
position: relative;
display: grid;
/* Sheet on the left, full-viewport spacer on the right. The user
scrolls between the two snap stops to open and close. */
grid-template-columns: var(--drawer-width) 100%;
overflow-x: scroll;
/* Stop the swipe from chaining into the page's vertical scroll
when the user reaches either snap edge. */
overscroll-behavior: none;
scrollbar-width: none;
/* `mandatory` guarantees the drawer always settles fully open or
fully closed — never half-open after a partial swipe. */
scroll-snap-type: x mandatory;
}
/* Enable smooth scrolling natively, but only if the user has not
requested reduced motion. */
@media (prefers-reduced-motion: no-preference) {
.Drawer-scroller {
scroll-behavior: smooth;
}
}
/* The empty spacer that creates the "closed" snap stop. */
.Drawer-scroller::after {
content: '';
scroll-snap-align: end;
/* Open the popover already scrolled to this stop (drawer off-screen),
so the JS only needs to scroll to the open position to
animate it in. */
scroll-initial-target: nearest;
}
.Drawer-sheet {
display: grid;
grid-template-rows: auto 1fr;
/* Use `svh` (small viewport height) — not `vh` or `dvh` — so the
sheet height does not jump when the iOS Safari address bar
resizes mid-swipe. */
height: 100svh;
background: #333;
color: #fff;
overflow-y: auto;
scroll-snap-align: start;
scrollbar-width: none;
}
배경 불투명도를 스크롤 위치에 연결
스크롤 기반 애니메이션은 스크롤러 범위에서 --drawer-backdrop를 1 (열림)에서 0 (닫힘)으로 매핑하므로 배경이 드래그와 완벽하게 동기화되어 페이드 인 및 페이드 아웃됩니다.
/* MANDATORY: Wrap this entire block in @supports. Browsers that don't
support animation-timeline still parse the @keyframes and would
apply the animation's `0%` value (--drawer-backdrop: 1) at all
times, leaving the backdrop permanently opaque. The @supports gate
ensures the animation is only registered where it actually works. */
@supports (animation-timeline: scroll()) {
.Drawer {
/* timeline-scope lets .Drawer reference a scroll-timeline that
is defined on its descendant (the scroller). Without this, the
timeline name is not visible to the .Drawer element. */
timeline-scope: --drawer-fade;
animation: fade-drawer-backdrop linear both;
animation-timeline: --drawer-fade;
}
.Drawer-scroller {
/* The horizontal scroll position of this element drives the
timeline named `--drawer-fade`. */
scroll-timeline: --drawer-fade x;
}
/* @property is REQUIRED. Without registering --drawer-backdrop with
a `<number>` syntax, the browser treats it as a string and cannot
interpolate it — the backdrop would jump from 0 to 1 with no
fade. */
@property --drawer-backdrop {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@keyframes fade-drawer-backdrop {
/* Scroll position 0 = drawer fully open = backdrop visible. */
0% { --drawer-backdrop: 1 }
/* Scroll position 100% = drawer fully closed = backdrop hidden. */
100% { --drawer-backdrop: 0 }
}
}
3. 창 열기 및 닫기
열기는 두 단계로 이루어집니다. 팝오버를 최상위 레이어로 승격한 다음 시트를 스크롤하여 표시합니다. 닫기는 한 단계로 이루어집니다. 스페이서로 다시 스크롤하면 시트가 완전히 화면에서 사라진 후 옵저버 (4단계)가 팝오버를 숨깁니다.
const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('drawer-open');
const scroller = drawer.querySelector('.Drawer-scroller');
const sheet = drawer.querySelector('.Drawer-sheet');
function openDrawer() {
// Show the popover first so the element is in the top layer before
// we trigger any scrolling. `scroll-initial-target` (set on the
// ::after spacer) places the initial scroll position at the closed
// stop, so the drawer enters the top layer already off-screen.
drawer.showPopover();
// Scroll the sheet into view. The `behavior: 'auto'` option defers
// to the CSS `scroll-behavior` property, which will be smooth unless
// the user prefers reduced motion. Snap takes over at the end and
// locks the drawer fully open.
scroller.scrollTo({left: 0, behavior: 'auto'});
}
function closeDrawer() {
// Scroll back to the spacer. Do NOT call hidePopover() here —
// doing so would remove the element from the top layer mid-animation
// and the close animation would not be visible. The
// IntersectionObserver in step 4 hides the popover once the sheet
// has actually left the viewport.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'auto'});
}
4. 열림 및 닫힘 상태 감지
스크롤 위치가 아닌 시트의 IntersectionObserver를 창 상태의 정보 소스로 사용합니다. 관찰자는 시트가 이동한 방식 (사용자 스와이프, 프로그래매틱 스크롤, 스냅 정착)과 관계없이 실행되므로 모든 닫기 경로가 동일한 콜백에서 수렴됩니다.
function onDrawerOpened() {
// Mark the rest of the page inert so keyboard and screen-reader
// users cannot tab into content hidden behind the drawer.
document.querySelector('main').inert = true;
openBtn.setAttribute('aria-expanded', 'true');
// Move focus into the drawer for keyboard users.
sheet.focus();
}
function onDrawerClosed() {
// Hide the popover only after the close animation completes,
// so the slide-out is visible to the user.
drawer.hidePopover();
document.querySelector('main').inert = false;
openBtn.setAttribute('aria-expanded', 'false');
}
// Treat "any pixel of the sheet visible inside the popover root" as
// "open enough to count as not closed". This threshold is intentionally
// tiny so the closed callback only fires once the sheet is truly gone.
const visibleThreshold = 1 / window.innerWidth;
const observer = new IntersectionObserver(
(entries) => {
// During programmatic scrolling the observer can deliver multiple
// entries in one batch. Only the most recent describes the
// current state; earlier entries are intermediate positions.
const entry = entries.at(-1);
if (entry.intersectionRatio < visibleThreshold) onDrawerClosed();
if (entry.intersectionRatio === 1) onDrawerOpened();
},
// root: drawer makes the popover element the intersection root,
// so the ratio reflects the sheet's visibility within the popover
// (i.e. how much of it has been swiped on-screen).
{root: drawer, threshold: [visibleThreshold, 1]},
);
observer.observe(sheet);
5. 트리거 및 닫기 핸들러 연결
// Open trigger.
openBtn.addEventListener('click', openDrawer);
// Light-dismiss: a tap on the dimmed area (anywhere inside the
// popover but outside the sheet) closes the drawer. We implement
// this manually because popover="manual" disables the browser's
// built-in light-dismiss (which would also fire mid-swipe — see step 1).
drawer.addEventListener('click', (event) => {
if (!sheet.contains(event.target)) closeDrawer();
});
// Escape key. Listen on document because focus may be inside the
// drawer when the user presses Escape.
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeDrawer();
});
대체 전략
Popover API, 배경을 페이드하는 스크롤 기반 애니메이션, scroll-initial-target는 광범위한 브라우저 지원이 필요한 경우 쉽게 구현할 수 있는 간단한 대체 기능이 포함된 점진적 개선입니다.
배경 페이드 대체(animation-timeline 지원 없음):
CSS.supports('animation-timeline: scroll()')로 감지하고 지원되지 않는 경우 scroll 이벤트 리스너에서 --drawer-backdrop를 작성합니다. 2단계의 CSS @supports 블록은 지원되지 않는 브라우저에서 키프레임이 적용되지 않도록 하므로 JavaScript 값이 유일한 작성자입니다.
if (!CSS.supports('animation-timeline: scroll()')) {
scroller.addEventListener('scroll', () => {
// Same mapping as the @keyframes: 0 scroll = 1 (open),
// sheet-width scroll = 0 (closed).
const ratio = 1 - scroller.scrollLeft / sheet.offsetWidth;
drawer.style.setProperty('--drawer-backdrop', ratio);
});
}
초기 스크롤 위치 대체(scroll-initial-target 지원 없음):
CSS.supports('scroll-initial-target', 'nearest')로 감지하고 openDrawer() 내에서 showPopover() 직후에 스크롤러를 닫힌 위치로 점프 스크롤합니다. 이 값이 없으면 드로어가 슬라이드인 애니메이션 없이 열린 위치에 즉시 표시됩니다.
async function openDrawer() {
drawer.showPopover();
if (!CSS.supports('scroll-initial-target', 'nearest')) {
// Jump-scroll to the closed stop so the scroll below
// animates the drawer in from off-screen.
scroller.scrollTo({left: scroller.offsetWidth, behavior: 'instant'});
// Wait two animation frames for the jump-scroll to commit.
// A single rAF is not enough — the second `scrollTo` would
// cancel the first before the browser has a chance to apply it.
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r))
);
}
scroller.scrollTo({left: 0, behavior: 'auto'});
}
@property 대체 (등록된 맞춤 속성 없음):
@property는 스크롤 기반 애니메이션이 키프레임 간에 --drawer-backdrop를 보간하기 때문에 필요합니다. 등록하지 않으면 속성이 문자열로 처리되고 페이드 없이 0과 1 사이를 이동합니다. 위의 스크롤 기반 애니메이션 대체가 적용된 경우 JavaScript는 모든 스크롤 프레임에서 --drawer-backdrop에 새 숫자 문자열을 작성하고 보간하지 않으므로 스크롤 기반 애니메이션을 지원하는 모든 브라우저가 @property도 지원하므로 별도의 @property 대체가 필요하지 않습니다.
Popover API 대체(popover 속성 지원 없음):
이 구성요소는 popover="manual"를 사용하고 JavaScript에서 완전히 닫기를 구현하므로 팝오버 API의 정의 동작(라이트 닫기, popovertarget 속성, 최상위 레이어 관리 이스케이프 처리 또는 포커스 관리)에 의존하지 않습니다. 실제로 사용하는 유일한 팝오버 기능은 최상위 레이어 프로모션(showPopover()를 통해)과 ::backdrop 의사 요소입니다.
더 광범위한 브라우저 지원이 필요한 경우 기능 감지를 분기하지 말고 팝오버를 전혀 사용하지 마세요. popover="manual" 속성을 삭제하고, 최상위 레이어 프로모션을 position: fixed 및 높은 z-index로 대체하고, ::backdrop를 동일하게 스타일이 지정된 동위 요소로 대체하고 (동일한 --drawer-backdrop 맞춤 속성 사용), showPopover()/hidePopover() 대신 클래스에서 공개 상태를 전환합니다. 구성요소의 나머지 부분 (스크롤 스냅, 스크롤 기반 배경 애니메이션, IntersectionObserver, 닫기 핸들러)은 변경되지 않습니다.