AppShell
데스크탑 앱(Tauri/macOS Overlay)의 3-패널 레이아웃 셸. Sidebar(back) + Main+SidePanel(foreground card) 구조.
데스크탑 앱의 외곽 윈도우 안에 깔리는 3-패널 셸. 좌측 사이드바, 중앙 본문, 우측 패널(예: 챗) 의 공통 구조를 하나의 컴포넌트군으로 제공한다.
CDS 는 윈도우 외곽(트래픽 라이트, 그림자, 모서리 squircle, 드래그-이동, 더블클릭-최대화) 을 떠안지 않는다. 이건 macOS Overlay 모드에서 OS 가, Windows/Linux 에서는 향후 별도 어댑터가 처리한다. AppShell 은 그 안의 레이아웃 + 시각 layer + 상태(접힘/폭) 만 책임진다.
레이어 모델
┌─ window (OS 가 외곽 처리) ─────────────────────────────────┐
│ │
│ ┌─ Sidebar ──┐ ┌─ Foreground Card ────────────────────┐ │
│ │ back │ │ Main │ SidePanel │ │
│ │ layer │ │ (front) │ (front) │ │
│ │ (gray) │ │ ↑ │ │
│ │ │ │ 사이드바와 만나는 좌측만 둥글게 │ │
│ └────────────┘ └───────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
- Sidebar = back layer. 좌측 가장자리에 깔리며 회색 톤.
- Main + SidePanel = foreground card. 사이드바 위로 떠있는 하나의 흰 카드.
모서리 라운딩
Foreground card 의 모서리는 background layer 와 만나는 쪽만 둥글게. 윈도우 가장자리에 닿는 쪽은 OS squircle 이 처리하므로 CDS 는 신경 쓰지 않는다.
자동 처리됨 (<AppShellMain> 의 data-adjacent-back-left 가 사이드바 열림 상태에 따라 토글).
Titlebar inset (macOS 트래픽 라이트 회피)
macOS Overlay 모드에서 트래픽 라이트(빨/노/초)가 좌상단 ~130px 영역을 차지한다. 그 자리에 우리 UI 가 닿으면 가려진다.
<AppShell>에leftInset={130}(또는 OS 별 적절값) 을 주입.- 좌/우 끝의 현재 열려있는 패널 헤더가 자동으로 inset 을 흡수한다.
- 사이드바 열림 →
AppShellSidebarHeader가 좌측 130px padding - 사이드바 닫힘 →
AppShellMainHeader가 좌측 130px padding (Main 이 leftmost 가 되므로)
- 사이드바 열림 →
- CDS 는 OS 를 직접 감지하지 않는다. 제품이 Tauri API 로 OS 를 보고 inset 값을 prop 으로 주입.
사용
import {
AppShell,
AppShellSidebar,
AppShellSidebarHeader,
AppShellSidebarBody,
AppShellSidebarFooter,
AppShellMain,
AppShellMainHeader,
AppShellMainBody,
AppShellPageHeader,
AppShellSidePanel,
AppShellSidePanelHeader,
AppShellSidePanelBody,
AppShellSplitter,
} from "@tendtoyj/cds-ui";
<AppShell leftInset={130}>
<AppShellSidebar defaultWidth={220} minWidth={200} maxWidth={320}>
<AppShellSidebarHeader>{/* 사이드바 토글 등 */}</AppShellSidebarHeader>
<AppShellSidebarBody>{/* 내비 */}</AppShellSidebarBody>
<AppShellSidebarFooter>{/* 설정 등 */}</AppShellSidebarFooter>
</AppShellSidebar>
<AppShellSplitter target="sidebar" doubleClickResetWidth={220} />
<AppShellMain>
<AppShellMainHeader>{/* breadcrumb 등 */}</AppShellMainHeader>
<AppShellMainBody>
<AppShellPageHeader>{/* page-level tabs/actions */}</AppShellPageHeader>
{/* 페이지 본문 */}
</AppShellMainBody>
</AppShellMain>
<AppShellSplitter target="sidePanel" doubleClickResetWidth={360} />
<AppShellSidePanel defaultWidth={360} minWidth={280} maxWidth={560}>
<AppShellSidePanelHeader>{/* 챗 탭 등 */}</AppShellSidePanelHeader>
<AppShellSidePanelBody>{/* 챗 메시지 등 */}</AppShellSidePanelBody>
</AppShellSidePanel>
</AppShell>;
Page header composition
<AppShellPageHeader>는 페이지가 소유한 tabs, filters, actions를 페이지 본문 가까이에서 선언하면서도
실제 DOM은 가장 가까운 <AppShellMainHeader>의 전용 slot에 렌더링한다. route 구성과 header 레이아웃을
서로 억지로 끌어올리지 않아도 페이지별 controls를 main titlebar에 합성할 수 있다.
import {
AppShell,
AppShellMain,
AppShellMainBody,
AppShellMainHeader,
AppShellPageHeader,
} from "@tendtoyj/cds-ui";
import { Plus } from "@tendtoyj/cds-icons/icons";
import { IconButton } from "@tendtoyj/cds-ui/components/icon-button";
import { Tabs, TabsList, TabsTrigger } from "@tendtoyj/cds-ui/components/tabs";
<AppShell>
<AppShellMain>
<AppShellMainHeader>{/* persistent leading content */}</AppShellMainHeader>
<AppShellMainBody>
<Tabs value={tab} onValueChange={setTab}>
<AppShellPageHeader>
<div className="flex w-full min-w-0 items-center justify-between gap-[12px]">
<TabsList>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="plugins">Plugins</TabsTrigger>
</TabsList>
<IconButton aria-label="Add item" onClick={addItem}>
<Plus />
</IconButton>
</div>
</AppShellPageHeader>
{/* Page content stays here. */}
</Tabs>
</AppShellMainBody>
</AppShellMain>
</AppShell>;
main header에 직접 선언한 persistent content가 먼저 배치되고 page-header slot이 남은 폭을 차지한다.
slot은 min-width: 0, overflow: hidden, 좌우 32px padding을 소유한다. 좁은 폭에서는 slot이 먼저
줄어들며, page header의 최상위 wrapper에도 w-full min-w-0와 필요한 truncate/shrink-0 규칙을
지정하는 것을 권장한다. padding 상수와 내부 slot/context는 public API가 아니다.
Lifecycle과 fallback
| 상황 | 동작 |
|---|---|
| AppShell 밖 | children을 inline 렌더링한다. 독립 preview와 SSR fixture에 유용하다. |
| AppShellMain 안, slot mount 전 | inline fallback을 만들지 않고 null을 렌더링한다. hydration과 본문 중복을 방지한다. |
| AppShellMainHeader 없음 | header content를 숨긴 채 유지한다. 본문 inline fallback은 만들지 않는다. |
| 활성 page header 없음 | main header에 빈 slot DOM을 만들지 않는다. |
| route 전환 또는 unmount | 등록을 정리하고 stale header content를 제거한다. |
여러 <AppShellPageHeader>가 동시에 mount되면 last-mounted wins다. 마지막 항목이 unmount되면 그 전에
mount되어 있던 항목이 다시 활성화된다. 이 정책은 전환 중 두 route tree가 잠깐 겹치는 경우에도 header가
중복되지 않게 한다. nested AppShell에서는 가장 가까운 <AppShellMain>의 slot을 사용하며 shell 사이에
content가 누출되지 않는다.
서버 렌더에서는 AppShellMain 안의 page header와 slot을 모두 생략한다. client의 첫 render도 동일한 markup을
사용하고 effect 이후에만 slot을 등록하므로 document 접근이나 hydration mismatch가 없다. 따라서
suppressHydrationWarning 같은 우회 설정은 필요하지 않다.
Electron / Tauri drag contract
page-header slot은 main header의 drag surface를 보존하기 위해 slot 전체를 no-drag로 바꾸지 않는다.
대신 slot 아래의 button, a, input, select, textarea에만
-webkit-app-region: no-drag를 적용한다. Electron에서는 page-level tabs/actions의 첫 클릭을 유지하고,
빈 header 영역은 계속 window drag에 사용할 수 있다. Tauri용 data-tauri-drag-region marker도 기존처럼
main header에 남으며 browser/docs에서는 두 규칙 모두 무해하다.
상태 모델 (Radix 식)
open / width 둘 다 controlled / uncontrolled 양쪽 지원.
{/* uncontrolled — 편하게 */}
<AppShellSidebar defaultOpen defaultWidth={220} />
{/* controlled — 단축키, URL 동기화, 다른 패널과 연동 등 */}
<AppShellSidebar
open={sidebarOpen}
onOpenChange={setSidebarOpen}
width={sidebarWidth}
onWidthChange={setSidebarWidth}
/>
defaultOpen/open/onOpenChangedefaultWidth/width/onWidthChangeminWidth/maxWidth— 드래그 한계resizable(defaulttrue) —false면 분할자가 비활성
닫힘 동작
open={false} 시 패널은 width 0 으로 줄어들지만 DOM 은 살아있고 내부 상태도 보존된다.
스크롤 위치, 입력값, 펼친 트리 등이 다시 열렸을 때 그대로 복귀.
Floating controls (overlay)
사이드바를 접어도 같은 좌표에 있어야 하는 토글 버튼처럼, 어떤 패널에도 속하지 않는 floating 컨트롤을 위한 슬롯.
<AppShellLeadingControls>— 좌상단.leftInset만큼 들여서 배치 (트래픽 라이트 회피).<AppShellTrailingControls>— 우상단.rightInset만큼 들여서 배치.
컨테이너 자체는 pointer-events: none 이라 패널 콘텐츠 클릭을 막지 않는다 — 내부 인터랙티브 자식만 클릭 가능.
<AppShell leftInset={72}>
<AppShellLeadingControls>
<IconButton aria-label="Toggle sidebar" onClick={toggleSidebar}>
<SidebarSimple />
</IconButton>
</AppShellLeadingControls>
<AppShellSidebar open={sidebarOpen}>...</AppShellSidebar>
...
</AppShell>
주의:
leftInset값은 트래픽 라이트 + leading controls 영역 전체를 덮을 만큼 충분히 크게 설정. 예: macOS 트래픽 라이트(~80px) + 토글 버튼(~32px) =leftInset={120}정도.
Splitter
target="sidebar" | "sidePanel"— 어느 패널의 width 를 조절할지 명시.- 위치는 사용자가 결정 (해당 패널 바로 옆 에 두는 것이 자연스러움).
- 대상 패널이
resizable={false}거나 닫혀있으면 자동 비활성. doubleClickResetWidth옵션 — 더블클릭 시 해당 폭으로 리셋.
Tauri 통합 (참고)
CDS 는 Tauri 와 직접 결합하지 않는다. 제품 레포에서 다음만 챙기면 된다.
- Tauri config — macOS 는
titleBarStyle: "Overlay"권장. - leftInset — macOS 라면 ~130, Windows/Linux 라면 0 (또는 별도값) 을
<AppShell>에 주입. - 드래그 영역 —
AppShell*Header에data-tauri-drag-region이 자동으로 박힌다.AppShellPageHeaderslot 아래의 표준 interactive element에는 Electron용no-drag도 자동 적용된다. - 풀스크린 인식 — 제품에서 풀스크린 상태면
leftInset={0}으로 동적 변경.
Props
<AppShell>
| Prop | 타입 | 기본 | 설명 |
|---|---|---|---|
titlebarHeight | number | 44 | titlebar 영역 높이(px). CSS 변수 --cds-app-shell-titlebar-height 로도 노출. |
leftInset | number | 0 | 좌측 패널 헤더가 비워줘야 할 가로 inset(px). macOS 트래픽 라이트 자리 회피. |
rightInset | number | 0 | 우측 패널 헤더가 비워줘야 할 가로 inset(px). |
<AppShellSidebar> / <AppShellSidePanel>
| Prop | 타입 | 기본 | 설명 |
|---|---|---|---|
defaultOpen | boolean | true | 초기 열림 상태 (uncontrolled). |
open | boolean | — | 현재 열림 상태 (controlled). |
onOpenChange | (open: boolean) => void | — | 열림 상태 변경 콜백. |
defaultWidth | number | Sidebar 240, SidePanel 360 | 초기 폭(px). |
width | number | — | 현재 폭(controlled). |
onWidthChange | (w: number) => void | — | 폭 변경 콜백 (드래그 시 호출). |
minWidth | number | Sidebar 200, SidePanel 280 | 드래그 최소 폭. |
maxWidth | number | Sidebar 320, SidePanel 560 | 드래그 최대 폭. |
resizable | boolean | true | false 면 Splitter 비활성. |
<AppShellSplitter>
| Prop | 타입 | 기본 | 설명 |
|---|---|---|---|
target | "sidebar" | "sidePanel" | — | 어느 패널의 width 를 조절할지. 필수. |
doubleClickResetWidth | number | — | 더블클릭 시 width 를 이 값으로 리셋. 미지정 시 더블클릭 비활성. |
<AppShell*Header>
| Prop | 타입 | 기본 | 설명 |
|---|---|---|---|
tauriDragRegion | boolean | true | data-tauri-drag-region 을 박을지. 자식 인터랙티브 요소와 충돌하면 끄기. |
<AppShellPageHeader>
| Prop | 타입 | 기본 | 설명 |
|---|---|---|---|
children | ReactNode | — | 가장 가까운 AppShell main header slot으로 portal할 page-level controls. AppShellMain 밖에서는 inline 렌더링. |
디자인 의도
- 컴포넌트 종류 자체에 layer (back/front) 와 position (leading/center/trailing) 의 의미가 박혀있다 —
<AppShellSidebar>는 항상 back layer 이고,<AppShellMain>은 항상 foreground card 의 시작점이다. - 사용자는 prop 으로 layer 를 지정하지 않는다. 대신 의도가 컴포넌트 이름에서 자명해야 한다.
- 새로운 패널 (예: 좌측 보조 패널, 우측 두 번째 패널) 이 필요해진다면 컴포넌트 분리 로 의미를 박는다 —
layer같은 일반 prop 으로 우회하지 않는다.