Drawer#
화면 측면에서 슬라이드하여 나타나는 모달 패널 컴포넌트입니다. 명령형 API (openDrawer) 로 호출하여 표시하고, 사용자가 닫으면 Future
가 결과와 함께 완료됩니다.
Live Preview#
class DrawerDefaultExample extends StatelessComponent {
const DrawerDefaultExample({super.key});
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (close) => Drawer(
title: 'Navigation',
content: div(
[
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
],
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
),
),
);
},
child: const Text('Open drawer'),
);
}
}
class DrawerDefaultExample extends StatelessWidget {
const DrawerDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (ctx) => Drawer(
title: 'Navigation',
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Dashboard'),
Text('Settings'),
Text('Profile'),
],
),
),
);
},
child: const Text('Open drawer'),
);
}
}
class DrawerChainExample extends StatelessComponent {
const DrawerChainExample({super.key});
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (close) =>
Drawer(
title: 'Navigation',
content: div(
[
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
],
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
),
).withStyle(
const CoreDrawerStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(
CoreRadius.radius32,
),
panelPadding: CoreEdgeInsets.all(CoreSpace.space32),
titleStyle: CoreTextStyle.token(
CoreTextStyles.titleLarge,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
},
child: const Text('Open drawer'),
);
}
}
class DrawerChainExample extends StatelessWidget {
const DrawerChainExample({super.key});
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (ctx) =>
Drawer(
title: 'Navigation',
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Dashboard'),
Text('Settings'),
Text('Profile'),
],
),
).withStyle(
const CoreDrawerStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(
CoreRadius.radius32,
),
panelPadding: CoreEdgeInsets.all(CoreSpace.space32),
titleStyle: CoreTextStyle.token(
CoreTextStyles.titleLarge,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
},
child: const Text('Open drawer'),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 보조 콘텐츠(필터, 설정, 상세 정보)를 화면 옆에서 슬라이드로 보여줄 때
- 메인 콘텐츠를 가리지 않고 추가 작업 공간이 필요할 때
- 모바일 네비게이션 메뉴를 구현할 때
대신 다른 컴포넌트를 사용하세요:
Dialog: 사용자 확인이 필요한 짧은 메시지일 때Popover: 특정 요소에 붙는 작은 팝업일 때Tabs: 콘텐츠를 탭으로 전환하는 것이 더 적합할 때
기본 사용법 (Basic Usage)#
CoUI 는 Flutter 와 Web 에서 동일한 명령형 API 를 제공합니다. 아래 예제 코드는 양쪽 플랫폼에서 그대로 사용할 수 있습니다.
// 트리거 버튼 클릭 시 드로어 열기
Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (close) => Drawer(
title: 'Navigation',
content: Column( // Web 은 div(...)
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Dashboard'),
Text('Settings'),
Text('Profile'),
],
),
),
);
},
child: const Text('Open drawer'),
)
openDrawer<T> 는 Future<T?> 를 반환합니다 — 사용자가 패널 안에서 close(value)
를 호출하면 그 값으로, 배리어/드래그/Escape/× 버튼으로 닫으면 null 로 완료됩니다.
// 결과를 받아오는 패턴
final picked = await openDrawer<String>(
context: context,
side: CoreDrawerSide.right,
builder: (close) => Drawer(
title: 'Pick a colour',
content: ColorList(onPick: (c) => close(c.name)),
),
);
Props / Parameters#
Drawer#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
content |
Widget / Component |
필수 | 드로어 내부에 표시할 콘텐츠 |
side |
CoreDrawerSide |
CoreDrawerSide.left |
슬라이드 인 방향 (left / right / top / bottom) |
title |
String? |
null |
드로어 상단 제목 텍스트 |
barrierDismissible |
bool |
true |
배리어(스크림) 클릭 시 드로어 닫기 |
transformBackdrop |
bool |
true |
열릴 때 앱 콘텐츠를 뒤로 축소(shadcn zoom-out — scale 0.95 + 코너 클립). sheet 는 이 변형이 없는 drawer. Flutter 는
CoUIApp
의 backdrop 호스트 필요(없으면 no-op). Web 은 드로어 루트를 브라우저
top-layer
(Popover API)로 승격해 조상 transform 을 벗어난 뒤 첫
data-coui-backdrop
래퍼를 직접 스타일(Vaul 방식) — SSR+아일랜드처럼 래퍼가 드로어 DOM 을 포함하는 구조에서도 동작. Popover API 미지원 브라우저는 그 구조에서만 안전하게 no-op(자식은 조상 transform 을 벗어날 수 없음)
|
draggable |
bool |
true |
가장자리를 50% 이상 드래그하면 닫힘 |
dismissOnEscape |
bool |
true |
Escape 키로 닫기 (Web 전용) |
drawerStyle |
CoreDrawerStyle? |
null |
panel chrome / barrier / animation / nested titleStyle·closeButtonStyle 묶음 |
API#
openDrawer<T>(...)#
| 인자 | 타입 | 기본값 | 설명 |
|---|---|---|---|
context |
BuildContext |
필수 | 가장 가까운 CoUIWeb / CoUIApp 또는 OverlayHost ancestor 를 찾는 데 사용 |
builder |
WidgetBuilder (Flutter) / Drawer Function(close) (Web) |
필수 | 드로어 panel 을 빌드하는 함수. Web 빌더는 close([T?]) 콜백을 받아 panel 안에서 결과와 함께 닫을 수 있음 |
side |
CoreDrawerSide |
필수 | 슬라이드 인 방향 (left / right / top / bottom) |
barrierDismissible |
bool |
true |
배리어 클릭 dismiss (Flutter — Web 은 panel 의 같은 이름 파라미터를 읽음) |
draggable |
bool |
true |
드래그 dismiss (Flutter — Web 은 panel 의 같은 이름 파라미터를 읽음) |
transformBackdrop |
bool |
true |
backdrop 축소 변형 (Flutter — Web 은 panel 의 같은 이름 파라미터를 읽음) |
animationHandle |
DrawerAnimationHandle? |
null |
열림 진행도를 외부에서 읽는 핸들 (Flutter 전용) |
drawerStyle |
CoreDrawerStyle? |
null |
route 레벨 chrome (배리어 색·애니메이션 duration) 해석용 (Flutter 전용) |
scrubController |
DrawerScrubController? |
null |
열림 진행도를 외부 제스처로 스크럽 (Web 전용) |
closeDrawer<T>(context, [result]) (Flutter)#
명령형으로 가장 위 드로어를 닫습니다 (Navigator.pop 과 동일한 의미).
Web 은 builder 의 close([T?]) 콜백을 사용하세요.
스타일 시스템 — drawerStyle#
Drawer 의 panel chrome / barrier / animation / 슬롯 미세 조정은 단일
drawerStyle (CoreDrawerStyle) 으로 흐릅니다. 시맨틱 enum (side) 과
동작 정책 (barrierDismissible, draggable, dismissOnEscape) 은 위젯
파라미터 그대로.
openDrawer(
context: context,
side: CoreDrawerSide.right,
builder: (ctx) => Drawer(
title: 'Settings',
content: ...,
drawerStyle: CoreDrawerStyle(
panelBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
panelWidth: CoreSpace.space224,
barrierColor: CoreColor.token(CoreColors.scrim),
barrierOpacity: CoreOpacity.opacity50,
openAnimationDuration: Duration(milliseconds: CoreDuration.moderate),
closeAnimationDuration: Duration(milliseconds: CoreDuration.normal),
titleStyle: CoreTextStyle.token(
CoreTextStyles.titleLarge,
color: CoreColor.token(CoreColors.onSurface),
),
),
),
)
CoreDrawerStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
panelBackgroundColor |
CoreColor? |
Panel background fill colour override. |
panelBorderColor |
CoreColor? |
Panel border stroke colour override. |
panelBorderRadius |
CoreBorderRadius? |
Panel border radius override. |
panelBorderWidth |
double? |
Panel border stroke width override (logical px). When null, defers to [defaultPanelBorderWidth] (1). |
panelWidth |
double? |
Panel width override for left / right drawers (logical px). |
panelHeight |
double? |
Panel height override for
top
/
bottom
drawers (logical px). When null (the default) the panel is
content-sized
— it wraps its content on the slide axis, capped at the viewport extent (greedy children such as
ListView
fill up to that cap). An explicit value fixes the extent instead. The null-means-content semantic is why this field carries no
defaultPanelHeight
— on both platforms absence is the
absence of a constraint
, not a value. Flutter hands it to
SizedBox.height
, where null adds no height constraint and the route's loose, screen-bounded constraints let the panel wrap; Web omits the
height
declaration altogether (
if (panelHeight != null)
) so the panel stays
height: auto
under its
max-height: 100%
cap. Any constant would pin every top / bottom drawer to one extent and take content-sizing off the table.
|
panelPadding |
CoreEdgeInsets? |
Panel inner content padding override. When null, defers to [defaultPanelPadding] (24 on all sides). Pass
CoreEdgeInsets.zero
to suppress the default content padding when the panel's
content
supplies its own.
|
headerPadding |
CoreEdgeInsets? |
Header padding override. When null, defers to [defaultHeaderPadding] (16 on all sides). |
panelBoxShadow |
List<CoreShadowLayer>? |
Panel elevation shadow override. |
shadowBaseColor |
CoreColor? |
Base colour the [panelBoxShadow] layers tint against. When null, defers to [defaultShadowBaseColor] (the
shadow
token).
|
barrierColor |
CoreColor? |
Barrier (scrim) overlay colour override. |
barrierOpacity |
double? |
Barrier (scrim) opacity multiplier override (0.0 – 1.0). When null, defers to [defaultBarrierOpacity] (0.8). |
backdropScale |
double? |
Backdrop scale-down factor override (0.0 – 1.0). When null, defers to [defaultBackdropScale] (0.95). Only takes effect when the widget's
transformBackdrop
behaviour flag is on.
|
backdropCornerRadius |
CoreBorderRadius? |
Corner radius override for the scaled backdrop clip. When null, defers to [defaultBackdropCornerRadius]. Asymmetric corners are honoured on both platforms — Flutter lerps the full
BorderRadius
per corner with the open progress, Web emits the full CSS
border-radius
shorthand.
|
openAnimationDuration |
Duration? |
Open animation duration override. |
closeAnimationDuration |
Duration? |
Close animation duration override. |
dragDismissThreshold |
double? |
Drag-to-dismiss threshold override expressed as a fraction of the panel's extent on the slide axis. |
titleStyle |
CoreTextStyle? |
Header title text style override — overlaid on the [defaultTitleStyle] (
titleMedium
role +
onSurface
tone) so the resolver composes one paint-ready title
TextStyle
(sb-text-style-repackage).
|
closeButtonStyle |
CoreButtonStyle? |
Header close (dismiss) button style override. The single entry point for the close-button chrome — padding / hit-area radius (
borderRadius
) / close glyph (
leadingIconStyle
) / hover transition (
animationDuration
) / focus-ring width (
focusOutlineStyle.borderWidth
). Merged onto [defaultCloseButtonStyle] and raw-forwarded to
Button(variant:.plain, buttonStyle: …)
(the
plain
variant owns the resting-opacity / hover-fade / foreground semantics).
|
Resolve chain#
design system default
→ CoreDrawerTheme.style // 프로젝트 공통
→ 부모 컴포넌트 슬롯 오버라이드
→ widget.drawerStyle // 인스턴스별
빠른 오버라이드 (Chain)#
이미 만든 Drawer 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class DrawerChainExample extends StatelessWidget {
const DrawerChainExample({super.key});
@override
Widget build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (ctx) =>
Drawer(
title: 'Navigation',
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Dashboard'),
Text('Settings'),
Text('Profile'),
],
),
).withStyle(
const CoreDrawerStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(
CoreRadius.radius32,
),
panelPadding: CoreEdgeInsets.all(CoreSpace.space32),
titleStyle: CoreTextStyle.token(
CoreTextStyles.titleLarge,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
},
child: const Text('Open drawer'),
);
}
}
class DrawerChainExample extends StatelessComponent {
const DrawerChainExample({super.key});
@override
Component build(BuildContext context) {
return Button(
variant: CoreButtonVariant.outline,
onPressed: () {
openDrawer<void>(
context: context,
side: CoreDrawerSide.left,
builder: (close) =>
Drawer(
title: 'Navigation',
content: div(
[
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
],
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
),
).withStyle(
const CoreDrawerStyle(
panelBackgroundColor: CoreColor.token(
CoreColors.surfaceContainerHighest,
),
panelBorderColor: CoreColor.token(CoreColors.primary),
panelBorderWidth: CoreStrokeWidth.stroke2,
panelBorderRadius: CoreBorderRadius.all(
CoreRadius.radius32,
),
panelPadding: CoreEdgeInsets.all(CoreSpace.space32),
titleStyle: CoreTextStyle.token(
CoreTextStyles.titleLarge,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
},
child: const Text('Open drawer'),
);
}
}
변형 (Variants)#
위치 (Side)#
openDrawer(side: CoreDrawerSide.left, ...) // 좌측
openDrawer(side: CoreDrawerSide.right, ...) // 우측
openDrawer(side: CoreDrawerSide.top, ...) // 상단
openDrawer(side: CoreDrawerSide.bottom, ...) // 하단
좌/우 드로어는 기본 너비 256 px (CoreDrawerStyle.defaultPanelWidth), 상/하 드로어는 기본적으로 콘텐츠 크기(뷰포트 상한 캡 —
ListView 같은 greedy 콘텐츠는 상한까지 채움)입니다.
동작 스펙 (Behavior)#
열기/닫기#
Drawer 는 명령형(imperative) 컴포넌트 입니다 (shadcn_flutter / shadcn-ui 와 동일한 모델). 상태 boolean 으로 제어하지 않고,
openDrawer 를 호출해 표시하고 다음 중 하나로 닫힙니다:
- 배리어(스크림) 탭 —
barrierDismissible: true(default) - 드로어 가장자리 드래그 —
draggable: true(default), 50 % threshold - Escape 키 —
dismissOnEscape: true(default, Web) - × 버튼 클릭 —
title이 있을 때 헤더에 표시 - 빌더의
close([result])콜백 (Web) /closeDrawer(context, result)(Flutter)
애니메이션#
-
슬라이드: 350 ms
ease-out(열기) /ease-out-cubic(닫기) —CoreDrawerStyle.defaultOpenAnimationDuration/defaultCloseAnimationDuration - 배리어 페이드: 350 ms
ease-out(양쪽 동일) - 드래그 중: transition 일시 정지 (panel 이 손가락을 즉시 따라감)
레이아웃#
- 좌/우 드로어: 너비
CoreDrawerStyle.defaultPanelWidth(256 px) × 화면 전체 높이 - 상/하 드로어: 화면 전체 너비 × 콘텐츠 높이 (뷰포트 상한 캡; shadcn_flutter 와 동일 모델)
CoreDrawerStyle.panelWidth/panelHeight로 오버라이드 가능
사용 가이드라인 (Usage Guidelines)#
✅ Do#
드로어에 명확한 제목과 닫기 방법을 제공하세요.
openDrawer(
context: context,
side: CoreDrawerSide.right,
builder: (close) => Drawer(
title: '필터 설정',
content: FilterPanel(onApply: (f) => close(f)),
),
)
title 이 있으면 자동으로 헤더 + × 버튼이 그려집니다. 사용자가 어떤 패널인지 파악하고 쉽게 닫을 수 있습니다.
❌ Don't#
짧은 확인 메시지에 Drawer 를 사용하지 마세요.
openDrawer(
context: context,
side: CoreDrawerSide.bottom,
builder: (close) => Drawer(
content: Text('정말 삭제하시겠습니까?'),
),
)
간단한 확인은 Dialog 가 더 적합합니다. Drawer 는 복잡한 콘텐츠/네비게이션용입니다.
✅ Do#
콘텐츠에 맞는 side 를 선택하세요.
- 네비게이션 메뉴 →
left - 보조 상세 / 필터 패널 →
right - 모바일 시트 / 액션 →
bottom - 알림 / 이벤트 시트 →
top
사용자 기대에 맞는 방향이어야 직관적입니다.
접근성 (Accessibility)#
키보드 인터랙션#
| 키 | 동작 |
|---|---|
Escape | 드로어 닫기 (dismissOnEscape: true 일 때) |
Tab | 드로어 내 요소 간 포커스 이동 |
Shift+Tab | 이전 요소로 포커스 이동 |
시맨틱#
-
Flutter:
Navigator.push로 별도 라우트 — 시스템 백 버튼 /PopScope가 자동 처리.Semantics(container: true, label: title) -
Web:
role="dialog"+aria-modal="true"+aria-label={title}
닫기 접근성#
- × 버튼 (
role="button"+aria-label="Close") 이 헤더에 표시되어 키보드/스크린 리더 접근 가능 - 배리어 탭 + 드래그 + Escape 키 — 다양한 dismiss 경로 제공
크로스 플랫폼 차이점 (Platform Differences)#
Drawer 는 Flutter / Web 에서 동일한 명령형 API (openDrawer<T>, Drawer
panel) 를 공유합니다. 아래는 플랫폼 내부 구현 차이만 나열합니다.
| 항목 | Flutter | Web |
|---|---|---|
| 오버레이 마운트 | Navigator.push(_DrawerRoute<T>) |
OverlayHost.dialog 레이어 portal |
| 애니메이션 | SlideTransition + Tween<Offset> 350 ms Curves.easeOut |
CSS transform: translateX 350 ms ease-out |
| 배리어 | PopupRoute.barrierColor |
<div class="absolute inset-0"> + click handler |
| 드래그 dismiss | GestureDetector + onPanUpdate/End |
mousedown/move/up listener |
| 닫기 API | closeDrawer(context, result) 또는 Navigator.pop(ctx, result) |
builder 의 close([result]) 콜백 |
| 시스템 back | PopScope 자동 | (없음) |
관련 컴포넌트 (Related Components)#
- Dialog: 모달 대화상자. 짧은 확인/입력에 적합 (Drawer 는 복잡한 콘텐츠용)
- Navigation: 네비게이션 컴포넌트. Drawer 안에 배치하여 모바일 메뉴 구현
- Popover: 요소에 붙는 팝업. Drawer 보다 작고 특정 컨텍스트에 연결
조합 예제#
// 모바일 필터 드로어 패턴
final filter = await openDrawer<FilterValue>(
context: context,
side: CoreDrawerSide.right,
builder: (close) => Drawer(
title: '필터',
content: FilterPanel(
onApply: (value) => close(value),
),
),
);
if (filter != null) applyFilter(filter);