Drawer | CoUI
LogoCoUI

Drawer

드로어/사이드바 컴포넌트

Drawer#

화면 측면에서 슬라이드하여 나타나는 모달 패널 컴포넌트입니다. 명령형 API (openDrawer) 로 호출하여 표시하고, 사용자가 닫으면 Future 가 결과와 함께 완료됩니다.

Live Preview#

사용 시기 (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 은 builderclose([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 를 호출해 표시하고 다음 중 하나로 닫힙니다:

  1. 배리어(스크림) 탭 — barrierDismissible: true (default)
  2. 드로어 가장자리 드래그 — draggable: true (default), 50 % threshold
  3. Escape 키 — dismissOnEscape: true (default, Web)
  4. × 버튼 클릭 — title 이 있을 때 헤더에 표시
  5. 빌더의 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) 를 공유합니다. 아래는 플랫폼 내부 구현 차이만 나열합니다.

항목FlutterWeb
오버레이 마운트 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]) 콜백
시스템 backPopScope 자동(없음)
  • 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);