App shell
패키지 네임스페이스가 아닌 레시피입니다. App shell은 제품 페이지를 둘러싼 틀입니다. 위쪽을 가로지르는 헤더, 본문 옆 Splitter 안의 Nav 사이드바, 그리고 둘이 들어가지 않는 너비 아래에서는 헤더의 버튼이 여는 Drawer 안의 사이드바로 이루어집니다. 페이지와 링크, 중단점(breakpoint)은 제품의 몫입니다. AppShell.* 내보내기는 없습니다.
이 페이지의 내용
핸들을 끌거나, 헤더의 첫 번째 버튼으로 내비게이션을 숨겨 보세요. 좁은 화면에서는 같은 버튼이 내비게이션을 드로어로 엽니다.
레이아웃
header가 맨 앞에 오고 그 시작에 건너뛰기 링크를 두며, 그다음 스플리터가 옵니다. 사이드바의 Nav.Root는 첫 번째 패널에, main은 두 번째 패널에 둡니다. main에 건너뛰기 링크가 가리키는 id와 tabIndex={-1}을 주어 링크가 포커스를 그 안으로 옮기게 하세요. 셸은 두 행짜리 그리드입니다. 헤더의 행과 나머지를 차지하는 행이며, 두 번째 행은 스플리터가 채웁니다. 각 패널이 자신의 내용을 스크롤하므로 헤더는 화면에 남습니다.
const sidebarId = useId();
const navButton = useRef<HTMLButtonElement>(null);
<div className="shell">
<Stack.Root as="header" direction="row" align="center" gap="condensed">
<VisuallyHidden.Root focusable>
<Link.Root href="#main">Skip to content</Link.Root>
</VisuallyHidden.Root>
<Button.Root ref={navButton} variant="ghost" aria-label="Navigation">
…
</Button.Root>
<Text.Root as="span" weight="bold">
Atlas
</Text.Root>
<Toolbar.Root aria-label="Account">…</Toolbar.Root>
</Stack.Root>
<Splitter.Root value={width} onValueChange={setWidth} min={180} max={360} collapsible>
<Splitter.Panel id={sidebarId}>…</Splitter.Panel>
<Splitter.Handle aria-label="Resize navigation" />
<Splitter.Panel>
<main id="main" tabIndex={-1}>
…
</main>
</Splitter.Panel>
</Splitter.Root>
</div>
.shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
block-size: 100dvh;
}
사이드바
사이드바의 너비는 스플리터의 value(픽셀)이므로, 창이 바뀌어도 너비를 유지하고 본문이 늘거나 줄어듭니다. collapsible이면 핸들에서 Enter를 눌러 사이드바를 숨기고 다시 보여 줄 수 있습니다. 핸들을 찾지 못하는 사람을 위해 헤더의 버튼도 같은 일을 합니다. 보관 중인 너비를 바꾸며, aria-expanded와 aria-controls가 패널을 가리킵니다. Enter가 되돌리는 너비는 스플리터가 따로 기억하므로, 버튼은 자신의 마지막 너비를 따로 기억합니다. 이 너비는 0보다 큰 변화마다 기록합니다. 끌어서 사이드바를 접은 뒤에는 끌기가 마지막으로 지난 너비인 min이 되며, Enter는 끌기 전의 너비로 되돌립니다. 너비는 제품이 환경설정을 두는 곳에 저장하고 value로 다시 넘기세요.
const [width, setWidth] = useState(240);
const [lastWidth, setLastWidth] = useState(240);
const resize = (next: number) => {
if (next > 0) setLastWidth(next);
setWidth(next);
};
const toggleSidebar = () => setWidth(width === 0 ? lastWidth : 0);
좁은 화면
중단점(여기서는 48rem) 아래에서는 사이드바가 시작 쪽 가장자리의 Drawer.Root로 옮겨 가고, 같은 헤더 버튼이 그것을 엽니다. 셸이 창을 채우므로 창의 너비를 matchMedia로 읽으세요. 그리고 스플리터에 show="end"를 설정합니다. 본문 패널만 보여 주고 사이드바의 패널과 핸들은 레이아웃에서 빼며, 본문 패널은 마운트된 채로 남습니다. 그래서 중단점을 넘어도(확대하면 넘을 수 있습니다) 페이지의 스크롤 위치와 입력한 내용, 그 안의 포커스가 그대로 남습니다. Nav.Root는 한 번에 한 곳에만 렌더링하세요. 넓을 때는 첫 번째 패널에, 좁을 때는 드로어에 둡니다.
서버에는 창이 없으므로 서버가 보낼 레이아웃을 정하세요. 여기처럼 좁은 레이아웃을 보내면 휴대폰에서 본문이 나뉘지 않고, 넓은 창은 하이드레이션하면서 아무것도 다시 마운트하지 않고 바뀝니다. 다만 사이드바는 첫 화면이 그려진 뒤에 나타납니다. 쿠키나 클라이언트 힌트로 너비를 아는 제품이라면 맞는 레이아웃을 보낼 수 있습니다.
import { MenuIcon, PanelLeft } from 'lucide-react';
const WIDE = '(min-width: 48rem)';
function subscribe(onChange: () => void) {
const query = window.matchMedia(WIDE);
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}
function useWide() {
return useSyncExternalStore(subscribe, () => window.matchMedia(WIDE).matches, () => false);
}
const wide = useWide();
const sidebarId = useId();
const navButton = useRef<HTMLButtonElement>(null);
const handle = useRef<HTMLDivElement>(null);
const sidebarHandoff = useFocusHandoff({ to: navButton });
// A window that widens shows the sidebar, so the drawer closes, and Drawer puts focus back on the button.
if (wide && drawerOpen) setDrawerOpen(false);
// `show` takes the handle out of the layout, so focus on it moves to the button.
useLayoutEffect(() => {
if (!wide && handle.current === handle.current?.ownerDocument.activeElement) navButton.current?.focus();
}, [wide]);
<Button.Root
ref={navButton}
variant="ghost"
aria-label="Navigation"
{...(wide ? { 'aria-controls': sidebarId, 'aria-expanded': width > 0 } : { 'aria-haspopup': 'dialog' as const })}
onClick={wide ? toggleSidebar : openDrawer}
>
{wide ? (
<Button.Icon>
<PanelLeft />
</Button.Icon>
) : (
<Button.Icon>
<MenuIcon />
</Button.Icon>
)}
</Button.Root>
<Splitter.Root show={wide ? 'both' : 'end'} value={width} onValueChange={resize} min={180} max={360} collapsible>
<Splitter.Panel id={sidebarId}>{wide && <div ref={sidebarHandoff}>{nav}</div>}</Splitter.Panel>
<Splitter.Handle ref={handle} aria-label="Resize navigation" />
<Splitter.Panel>…</Splitter.Panel>
</Splitter.Root>
버튼은 두 레이아웃에서 같은 요소로 남으므로, 레이아웃이 바뀌어도 포커스를 유지합니다. 넓을 때는 사이드바를 숨기고 보여 주며, 좁을 때는 대화상자를 엽니다. aria-haspopup="dialog"가 그것을 알립니다. 포커스를 가진 채 언마운트되는 사이드바는 useFocusHandoff로 포커스를 버튼에 넘기고, 마운트된 채 남는 핸들의 포커스는 레이아웃 이펙트에서 버튼으로 옮기므로, 창이 좁아질 때 포커스가 페이지로 떨어지지 않습니다.
페이지 이동
현재 페이지의 링크에는 aria-current="page"를 표시하며, 이것은 라우터가 설정합니다(Nav 참고). 사이드바에서는 포커스가 사용자가 고른 링크에 남으므로 다음 링크로 이어서 갈 수 있습니다. 경로가 바뀐 것을 알려 주는 라우터도 있습니다. 그렇지 않은 라우터라면 useAnnouncer로 새 페이지의 제목을 알리세요.
드로어의 링크든 뒤로 가기든 페이지가 바뀔 때마다, 새 페이지를 보여 주는 렌더링에서 드로어를 닫으세요. 이 확인은 내비게이션 링크가 바꿀 수 있는 모든 것, 즉 경로와 쿼리 문자열, 그리고 링크가 쓴다면 해시를 기준으로 하세요. 고른 링크는 드로어와 함께 사라지므로 새 페이지의 제목으로 포커스를 보냅니다. restoreFocusRef는 드로어가 열린 동안 페이지가 바뀌었으면 제목을, 그렇지 않으면 헤더 버튼을 가리키므로, 아직 이동이 끝나지 않은 링크를 누른 뒤에도 Escape와 닫기 버튼은 버튼으로 포커스를 돌려줍니다. Drawer는 닫히고 난 뒤, 새 페이지가 렌더링된 다음에 ref를 읽으므로 제목은 새 페이지의 것입니다. 현재 페이지의 링크는 페이지를 바꾸지 않으므로 직접 드로어를 닫고, 포커스는 헤더 버튼으로 돌아갑니다. 수정 키를 누른 클릭은 링크를 다른 곳에서 열므로 드로어를 열어 둡니다.
const heading = useRef<HTMLElement>(null);
const [navigated, setNavigated] = useState(false);
// `url` comes from your router's location hook: the path, the query string and the hash.
const [shownUrl, setShownUrl] = useState(url);
if (url !== shownUrl) {
setShownUrl(url);
setNavigated(drawerOpen && !wide);
setDrawerOpen(false);
}
const openDrawer = () => {
setNavigated(false);
setDrawerOpen(true);
};
<Drawer.Root open={drawerOpen} onOpenChange={setDrawerOpen} side="start" restoreFocusRef={navigated ? heading : navButton}>
<Drawer.Header>
<Drawer.Title>Navigation</Drawer.Title>
<Drawer.Close />
</Drawer.Header>
<Drawer.Body
onClick={(event) => {
const elsewhere = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!elsewhere && (event.target as Element).closest('a[aria-current="page"]')) setDrawerOpen(false);
}}
>
{!wide && nav}
</Drawer.Body>
</Drawer.Root>
<Text.Root as="h1" ref={heading} tabIndex={-1}>
{page.title}
</Text.Root>
접근성을 지키는 조합
Todae가 처리하는 것: Nav.Root의 nav 랜드마크, 목록, 현재 링크의 모양. Splitter의 크기 조절 사이드바: 핸들의 역할, 값, 키와 포인터, Enter로 접기, 그리고 탭 순서와 접근성 트리에서 빠지는 접힌 패널. show로 두 패널을 모두 마운트한 채 본문 패널만 보여 주기. Drawer.Root의 모달 드로어: 포커스를 가져오고, Escape로 닫히고, 뒤의 페이지를 비활성(inert)으로 만들고, 버튼이나 restoreFocusRef로 포커스를 돌려줍니다. Toolbar.Root의 헤더 동작들을 위한 Tab 정지점 하나. VisuallyHidden.Root focusable로 포커스가 있는 동안 보이는 건너뛰기 링크. useFocusHandoff로 포커스를 가진 채 언마운트되는 사이드바에서 헤더 버튼으로 옮겨 가는 포커스.
제품이 제공하는 것: 페이지와 링크, 그리고 어느 것이 현재 페이지인지. header와 main 랜드마크, 건너뛰기 링크와 그 대상. 내비게이션, 드로어, 핸들, 헤더 버튼, 툴바의 이름. 중단점과 서버가 보낼 레이아웃. 한 번에 한 곳에만 두는 내비게이션. 창이 넓어지거나 페이지가 바뀔 때 드로어를 닫는 일과 그때 포커스가 갈 곳. show가 핸들을 숨기기 전에 핸들에서 포커스를 옮기는 일. 사이드바 너비 저장.
주의할 점: 중단점 아래에서 show 대신 다른 레이아웃으로 바꾸는 경우: 페이지가 다시 마운트되어, 확대한 사람이 보던 위치와 입력한 내용을 잃습니다. 닫힌 드로어 안처럼 페이지 어디든 있는 내비게이션의 두 번째 사본: 숨기는 방법이 바뀌면 같은 이름의 nav 랜드마크가 둘이 됩니다. 창이 넓어지거나 뒤로 간 뒤에도 열려 있는 드로어. 포커스를 헤더 버튼으로 돌려보내는 드로어 링크: 스크린 리더가 새 페이지의 제목이 아니라 맨 위에 남습니다. tabIndex={-1}이 없는 건너뛰기 링크 대상: 페이지는 움직이지만 모든 스크린 리더의 읽기 위치가 따라가지는 않습니다. 레이아웃이 좁아질 때 핸들에 남은 포커스: show가 핸들을 레이아웃에서 빼므로 포커스가 페이지로 떨어집니다.
언제 쓰나요
언제: 페이지가 여럿이고 내비게이션이 계속 보이는 앱의 틀. 대시보드, 관리 화면, 작업 공간 등.
쓰지 않을 때: 최상위 페이지가 몇 개뿐인 사이트 → 헤더 안의 nav에 둔 링크. 한 페이지의 여러 보기 → Tabs. 페이지 안에서 크기를 조절하는 두 영역 → Splitter만. 본문 옆의 작업용 사이드 패널 → Drawer.