NavigationMenu
호버 또는 클릭으로 콘텐츠 패널이 펼쳐지는 가로 메가-메뉴 컴포넌트
목차
호버 또는 클릭으로 콘텐츠 패널이 펼쳐지는 가로 메가-메뉴 컴포넌트입니다. 각 상위 항목은 콘텐츠 패널을 가지거나, 직접 링크이거나, 액션 콜백을 가질 수 있습니다.
NavigationMenu는 데이터 기반 컴포넌트입니다. CoreNavigationMenuItem
목록을 넘기면 각 항목이 가로 트리거 바에 라벨로 표시되고, content가 있는
항목은 호버/클릭 시 드롭다운 패널로 펼쳐집니다. Flutter와 Web이 동일한 파라미터
이름·동작을 갖습니다.
기본 사용법 (Basic Usage)#
NavigationMenu(
items: [
CoreNavigationMenuItem(
label: 'Products',
content: const Text('Browse the product catalog'),
),
CoreNavigationMenuItem(label: 'Pricing', href: '/pricing'),
CoreNavigationMenuItem(label: 'About', onTap: () {}),
],
)
NavigationMenu(
items: [
CoreNavigationMenuItem(
label: 'Products',
content: const Text('Browse the product catalog'),
),
CoreNavigationMenuItem(label: 'Pricing', href: '/pricing'),
CoreNavigationMenuItem(label: 'About', onTap: () {}),
],
)
빠른 오버라이드 (Chain)#
이미 만든 NavigationMenu 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class NavigationMenuChainExample extends StatelessWidget {
const NavigationMenuChainExample({super.key});
@override
Widget build(BuildContext context) {
return NavigationMenu(
items: [
CoreNavigationMenuItem(
label: 'Products',
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
Text('Web Apps').titleSmall.medium,
Text('Build modern web experiences').bodySmall.onSurfaceVariant,
Text('Mobile Apps').titleSmall.medium,
Text('Native iOS and Android apps').bodySmall.onSurfaceVariant,
],
),
),
CoreNavigationMenuItem(
label: 'Solutions',
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
Text('For Startups').titleSmall.medium,
Text('Scale from zero to launch').bodySmall.onSurfaceVariant,
Text('For Enterprise').titleSmall.medium,
Text('Security and compliance built in').bodySmall.onSurfaceVariant,
],
),
),
CoreNavigationMenuItem(label: 'Pricing', onTap: () {}),
CoreNavigationMenuItem(label: 'About', onTap: () {}),
],
).withStyle(
const CoreNavigationMenuStyle(
triggerPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
triggerBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
triggerHoverColor: CoreColor.token(CoreColors.secondaryContainer),
triggerSpacing: CoreSpace.space12,
),
);
}
}
class NavigationMenuChainExample extends StatelessComponent {
const NavigationMenuChainExample({super.key});
@override
Component build(BuildContext context) {
return NavigationMenu(
items: [
CoreNavigationMenuItem(
label: 'Products',
content: div(
[
Text('Web Apps').titleSmall.medium,
Text('Build modern web experiences').bodySmall.onSurfaceVariant,
Text('Mobile Apps').titleSmall.medium,
Text('Native iOS and Android apps').bodySmall.onSurfaceVariant,
],
styles: const Styles(
display: Display.flex,
flexDirection: FlexDirection.column,
gap: jaspr_dom.Gap.all(Unit.pixels(CoreSpace.space8)),
),
),
),
CoreNavigationMenuItem(
label: 'Solutions',
content: div(
[
Text('For Startups').titleSmall.medium,
Text('Scale from zero to launch').bodySmall.onSurfaceVariant,
Text('For Enterprise').titleSmall.medium,
Text('Security and compliance built in').bodySmall.onSurfaceVariant,
],
styles: const Styles(
display: Display.flex,
flexDirection: FlexDirection.column,
gap: jaspr_dom.Gap.all(Unit.pixels(CoreSpace.space8)),
),
),
),
CoreNavigationMenuItem(label: 'Pricing', onTap: () {}),
CoreNavigationMenuItem(label: 'About', onTap: () {}),
],
).withStyle(
const CoreNavigationMenuStyle(
triggerPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
triggerBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
triggerHoverColor: CoreColor.token(CoreColors.secondaryContainer),
triggerSpacing: CoreSpace.space12,
),
);
}
}
Live Preview#
코드 보기
class NavigationMenuDefaultExample extends StatelessComponent {
const NavigationMenuDefaultExample({super.key});
@override
Component build(BuildContext context) {
return NavigationMenu(
items: [
CoreNavigationMenuItem(
label: 'Products',
content: div(
[
Text('Web Apps').titleSmall.medium,
Text('Build modern web experiences').bodySmall.onSurfaceVariant,
Text('Mobile Apps').titleSmall.medium,
Text('Native iOS and Android apps').bodySmall.onSurfaceVariant,
],
styles: const Styles(
display: Display.flex,
flexDirection: FlexDirection.column,
gap: Gap.all(Unit.pixels(CoreSpace.space8)),
),
),
),
CoreNavigationMenuItem(
label: 'Solutions',
content: div(
[
Text('For Startups').titleSmall.medium,
Text('Scale from zero to launch').bodySmall.onSurfaceVariant,
Text('For Enterprise').titleSmall.medium,
Text('Security and compliance built in').bodySmall
.onSurfaceVariant,
],
styles: const Styles(
display: Display.flex,
flexDirection: FlexDirection.column,
gap: Gap.all(Unit.pixels(CoreSpace.space8)),
),
),
),
CoreNavigationMenuItem(label: 'Pricing', onTap: () {}),
CoreNavigationMenuItem(label: 'About', onTap: () {}),
],
);
}
}
class NavigationMenuDefaultExample extends StatelessWidget {
const NavigationMenuDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return NavigationMenu(
items: [
CoreNavigationMenuItem(
label: 'Products',
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
Text('Web Apps').titleSmall.medium,
Text('Build modern web experiences').bodySmall.onSurfaceVariant,
Text('Mobile Apps').titleSmall.medium,
Text('Native iOS and Android apps').bodySmall.onSurfaceVariant,
],
),
),
CoreNavigationMenuItem(
label: 'Solutions',
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
Text('For Startups').titleSmall.medium,
Text('Scale from zero to launch').bodySmall.onSurfaceVariant,
Text('For Enterprise').titleSmall.medium,
Text('Security and compliance built in').bodySmall
.onSurfaceVariant,
],
),
),
CoreNavigationMenuItem(label: 'Pricing', onTap: () {}),
CoreNavigationMenuItem(label: 'About', onTap: () {}),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사이트 전역 네비게이션에서 카테고리별 풍부한 콘텐츠 패널이 필요할 때
- 마케팅 사이트의 헤더처럼 제품/솔루션을 메가-메뉴로 정리할 때
- 호버로 미리보기를 제공하면서 클릭으로도 동작해야 할 때
대신 다른 컴포넌트를 사용하세요:
Menubar: 데스크탑 앱 스타일의 파일/편집 메뉴 바에DropdownMenu: 단일 버튼의 드롭다운 액션 목록에Menu: 사이드바 형태의 일반 메뉴에
이런 곳에는 쓰지 마세요:
- 트리거 바의 intrinsic 폭보다 좁은 컨테이너 — 좁으면 플랫폼마다 다르게 깨집니다 (아래 "크로스 플랫폼 차이점" 참조).
Props / Parameters#
W는 드롭다운 합성 콘텐츠 타입으로 Flutter의 Widget, Web의 Component입니다.
NavigationMenu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<CoreNavigationMenuItem<W>> |
필수 | 가로 트리거 바에 표시되는 상위 항목 목록 |
activeIndex |
int? |
null |
활성 항목 인덱스. 지정 시 제어 모드, 생략 시 내부 상태 관리 |
onActiveIndexChanged |
void Function(int?)? |
null |
활성 항목이 바뀔 때 호출 (null이면 닫힘) |
variant |
CoreNavigationMenuVariant |
standard |
시각 변형 |
navigationMenuStyle |
CoreNavigationMenuStyle? |
null |
인스턴스별 chrome / 치수 스타일 |
CoreNavigationMenuItem#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String | 필수 | 가로 트리거 바에 표시되는 라벨 |
content |
W? |
null |
펼쳐지는 드롭다운 콘텐츠 (콘텐츠 항목) |
href |
String? |
null |
직접 링크 URL (링크 항목) |
onTap |
CoreVoidCallback? |
null |
액션 항목 활성화 시 호출 |
스타일 시스템 (Style System)#
NavigationMenu의 모든 chrome / 치수 오버라이드는 단일
CoreNavigationMenuStyle 슬롯으로 흐릅니다. 가로 트리거
바와 드롭다운 패널 chrome을 모두 한 클래스가 받습니다.
시맨틱 vs 스타일#
-
시맨틱 / behaviour: 위젯 파라미터로 직접 (
variant,items,activeIndex) -
chrome / dimensional:
navigationMenuStyle: CoreNavigationMenuStyle?한 곳으로
Resolve chain#
CoreNavigationMenuStyle.defaultX // 디자인 시스템 기본값
→ CoreNavigationMenuTheme.style // 프로젝트 공통
→ CoreNavigationMenuTheme.variantStyles[variant] // variant별 공통
→ widget.navigationMenuStyle // 인스턴스별
CoreNavigationMenuStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
triggerPadding |
CoreEdgeInsets? |
Padding applied to each top-level trigger. |
triggerBorderRadius |
CoreBorderRadius? |
Corner radius of a top-level trigger. |
triggerHoverColor |
CoreColor? |
Hover / active background colour of a trigger. |
triggerButtonStyle |
CoreButtonStyle? |
Nested style for the top-level trigger
Button(variant: menubar)
— the single source for the trigger's button chrome (padding, radius, label → chevron gap, hover fill). Resolvers raw-forward
defaultTriggerButtonStyle.merge(this)
to the composed
Button
(no resolver-side assembly).
null
falls back to [defaultTriggerButtonStyle].
|
contentTriggerButtonStyle |
CoreButtonStyle? |
What a trigger that owns a dropdown (and so shows a chevron) changes on top of [triggerButtonStyle]. Merged over [defaultContentTriggerButtonStyle];
null
keeps that default.
|
openTriggerButtonStyle |
CoreButtonStyle? |
What a trigger changes on top of its content-trigger style while its dropdown is open. Merged over [defaultOpenTriggerButtonStyle];
null
keeps that default.
|
triggerSpacing |
double? |
Spacing between adjacent top-level triggers in the bar (logical px, pre-scaling). Flows directly into
Row.spacing
(Flutter) / inline CSS
gap
(Web) — both native paint-API forms that consume raw doubles, so the slot stays scalar (
double?
). The label → chevron gap is not this value: it is [triggerButtonStyle]'s
iconLabelGapStyle
. Falls back to [defaultTriggerSpacing] when null.
|
triggerTextStyle |
CoreTextStyle? |
Top-level trigger label text style override. Typography role + weight + colour are carried in this single [CoreTextStyle] slot (sb-text-style-repackage). Text colour is carried via [CoreTextStyle.color] inside this slot. Overlaid on [defaultTriggerTextStyle] (
labelLarge
role + medium weight +
onSurface
colour) when null.
|
chevronIconStyle |
CoreIconStyle? |
Chevron disclosure icon style of a content trigger. Falls back to [defaultChevronIconStyle] when null. |
panelStyle |
CorePopupStyle? |
Nested panel-box chrome for the dropdown content panel — the panel is a
Popup
, so the resolver raw-forwards this (merged over [defaultPanelStyle] + the variant's [defaultsByVariant] colours) to the composed
Popup
's
popupStyle
; the popup resolver fills the rest from [CorePopupStyle]'s own defaults. Overlaid by the per-instance value.
|
menuOffset |
double? |
Vertical gap between the trigger bar and a dropdown panel (logical px). |
duration |
Duration? |
Open / close colour-transition duration. |
closeDelay |
Duration? |
Grace period between the pointer leaving the menu and the panel closing. Falls back to [defaultCloseDelay] when null. |
panelSwitchDuration |
Duration? |
Duration of the panel's slide when the open menu moves from one trigger to another. Falls back to [defaultPanelSwitchDuration] when null. |
CoreNavigationMenuStyle 변형별 기본값 (CorePopupStyle)#
| 필드 | standard |
|---|---|
backgroundColor | surfaceBright |
borderColor | outlineVariant |
사용 예 (Flutter / Web 동일)#
NavigationMenu(
navigationMenuStyle: const CoreNavigationMenuStyle(
panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
panelBackgroundColor: CoreColor.token(CoreColors.surface),
),
items: [
CoreNavigationMenuItem(
label: 'Products',
content: const Text('Browse the product catalog'),
),
],
)
동작 스펙 (Behavior)#
인터랙션#
- 호버:
content를 가진 트리거에 호버하면 패널이 펼쳐짐 -
클릭:
content트리거 클릭 시 토글,href트리거는 링크 이동,onTap트리거는 콜백 호출 - 외부 클릭: 메뉴 외부 클릭 시 닫힘
- Escape: 열린 패널 닫힘
-
호버 이탈: 트리거나 패널을 벗어나면
closeDelay(기본 200ms) 후 닫힘. 그 사이 메뉴 어느 부분으로든 다시 들어오면 취소됩니다 — 트리거와 패널 사이 간극을 건너는 동안 패널이 깜빡이며 닫히지 않도록.
트리거 모양#
-
트리거는 높이 40, 모서리 8, 라벨 14 Medium(
on-surface)입니다. 바에서 트리거 사이는 2(triggerSpacing), 라벨과 chevron 사이는 4(triggerButtonStyle의iconLabelGapStyle)입니다. -
안쪽 여백은 위아래 8입니다. 드롭다운이 있는 트리거(chevron 이 있는 트리거)는
시작쪽 12 · 끝쪽 8, 없는 트리거(링크 · 동작)는 양쪽 12 입니다. 시작 / 끝은
읽는 방향을 따르므로 오른쪽에서 왼쪽으로 쓰는 로케일에서는 거울상이 됩니다
(
contentTriggerButtonStyle). -
호버와 열림 배경은 같은
surface-container입니다. 열린 트리거는 포인터가 패널로 옮겨 가도 그 배경을 유지합니다(openTriggerButtonStyle).Button의menubar변형이 가진 선택 배경(surface-container-highest)은Menubar와 공유하는 값이라 이 컴포넌트는 쓰지 않습니다. - chevron 은
on-surface-variant, 드롭다운 판 모서리는 12(패널 역할)입니다.
상태 전환#
closed→open: 콘텐츠 트리거 호버 / 클릭-
open→open (다른 항목): 다른 콘텐츠 트리거 호버 / 클릭. 패널은 마운트를 유지한 채 이동합니다 — 닫혔다 다시 열리지 않습니다. open→closed: 외부 클릭, Escape,closeDelay경과
애니메이션#
- 트리거 hover 배경: 150ms 색상 전환 (
CoreDuration.ms150) - chevron 회전: 활성 시 180° (
CoreDuration.ms150) -
패널 전환(트리거 → 트리거):
panelSwitchDuration(기본 300ms,CoreEasing.move) 동안 패널이 이전 위치에서 새 위치로 이동하고, 콘텐츠는 이동 방향에 따라 방향성 크로스페이드로 교차합니다. 처음 열기와 완전히 닫기의 모션은 이와 별개로 패널Popup의CorePopupStyle이 소유합니다.
사용 가이드라인 (Usage Guidelines)#
상단 항목에서 풍부한 드롭다운 콘텐츠·직접 링크·액션을 함께 제공하려면 NavigationMenu를 사용합니다. 선택 목적지의 bar/rail/sidebar는 NavigationBar, 앱 명령을 담는 메뉴바는 Menubar, 패널을 전환하는 탭은 Tabs를 선택합니다.
✅ Do#
항목 종류(콘텐츠 / 링크 / 액션)를 하나만 지정하기
CoreNavigationMenuItem(
label: 'Pricing',
href: '/pricing',
onTap: () => context.go('/pricing'), // Flutter 라우팅은 onTap 이 담당
)
Web은 href로 실제 <a href>를 렌더해 브라우저 네비게이션이 일어나지만, Flutter는 href를 Semantics(link: true) 표시에만 쓰고 실제 이동 로직이 없습니다 — onTap이 없으면 Flutter에서는 클릭해도 아무 일도 일어나지 않습니다.
❌ Don't#
content와 href를 같은 항목에 동시에 지정하지 않기
// ❌ content 와 href 를 동시에
CoreNavigationMenuItem(
label: 'Docs',
href: '/docs',
content: const Text('Docs preview'),
)
Web은 href가 있으면 무조건 실제 <a href>를 렌더해 content를 아예 그리지 않는 반면, Flutter는 content가 있으면 탭이 항상 드롭다운을 토글하고 href는 접근성 표시에만 쓰입니다 — 같은 코드가 Web에선 "링크", Flutter에선 "드롭다운"으로 다르게 동작합니다.
트리거 바의 intrinsic 폭보다 좁은 컨테이너에 두지 않기
// ❌ 항목 라벨을 다 담지 못하는 폭
SizedBox(
width: 120,
child: NavigationMenu(
items: const [
CoreNavigationMenuItem(label: 'Products'),
CoreNavigationMenuItem(label: 'Solutions'),
CoreNavigationMenuItem(label: 'Pricing'),
],
),
)
NavigationMenu 는 트리거를 줄이거나 감싸거나 스크롤하지 않습니다. 좁아지면
Flutter 는 오버플로 경고를 그리고, Web 은 아무 신호 없이 테두리 밖으로
흘러넘칩니다. 이 갈림은 의도적으로 해소하지 않은 상태이므로, 모든 트리거가
들어가는 폭을 호출자가 보장합니다.
접근성 (Accessibility)#
역할#
WAI-ARIA disclosure navigation 패턴을 따릅니다 — 메뉴가 아니라 사이트 내비게이션입니다.
-
가로 바: 네이티브
<nav>랜드마크 + 로컬라이제이션navigationLabel에서 온aria-label— 명시role은 없습니다(요소가 이미 랜드마크) - 콘텐츠 트리거: 네이티브
<button>+aria-expanded - 링크 트리거: 네이티브
<a href> - 드롭다운 패널: 역할 없음 — 안의 링크·텍스트가 제 역할을 그대로 가집니다
감싸지 않습니다: 컴포넌트가 이미 <nav> 랜드마크이므로 호출하는 쪽에서 또
<nav> 로 감싸면 같은 내비게이션이 랜드마크 두 겹이 되어 스크린 리더 목록에 두 번
나옵니다. 레이아웃용 래퍼가 필요하면 <div> 를 씁니다.
라벨은 바꿀 수 있습니다 (Web): 기본 라벨은 로컬라이제이션 navigationLabel
이고, attributes: {'aria-label': '주요'} 처럼 넘긴 값이 그것을 이깁니다. 한 페이지에
내비게이션 랜드마크가 여럿이면(헤더 · 사이드바 · 푸터) 각자 이름을 달아야 리더의
랜드마크 목록에서 서로를 구별할 수 있습니다. Flutter 에는 아직 이 경로가 없어 항상
기본 라벨을 씁니다 — #5065.
왜 menubar/menu 가 아닌가: 패널에는 링크·카드·텍스트 같은 임의 콘텐츠가
들어가고, 그것들은 menu 가 약속하는 menuitem 이 될 수 없습니다. 메뉴 역할을
주장하면 리더는 화살표 메뉴 조작을 기대하는데, 안의 링크는 그것을 줄 수 없습니다.
Flutter 가 이미 링크·버튼·확장 상태로 알리는 것과 같은 구조입니다.
키보드#
| 키 | 동작 |
|---|---|
Tab | 트리거(링크·버튼)와 열린 패널 안의 링크를 차례로 이동 — 트리거마다 tab stop |
Enter / Space |
콘텐츠 버튼의 패널 열기/닫기. Web 네이티브 링크는 Enter 로 이동하며 Space 로 이동하지 않음 |
Escape | 열린 패널 닫기 |
스크린 리더#
-
Flutter: 루트가
navigationLabel이름을 단 navigation 랜드마크(SemanticsRole.navigation)이고, 트리거는 그 아래 각자의 노드입니다 — 콘텐츠 트리거는 버튼 + 펼침 상태(expanded), 링크 트리거는 링크(link) -
Web: 네이티브
<nav>랜드마크(기본 라벨navigationLabel, 호출자aria-label이 우선) + 네이티브 링크·버튼, 콘텐츠 트리거의aria-expanded -
남은 차이: Flutter 링크 트리거는
Button위에 링크 표시를 얹으므로 버튼 표시도 함께 나갑니다(Web 은<a>하나). 랜드마크 라벨을 바꾸는 경로는 Web 에만 있습니다(#5065).
트리거 이름은 CoreNavigationMenuItem.label 에서 옵니다. 패널은 호출자가 넘긴
content 자신의 의미를 유지하며 별도 이름을 붙이지 않습니다. 펼침 상태는
expanded / aria-expanded 로 전달되고 별도 live region 은 없습니다.
포커스 관리#
- 진입 — Flutter 는 모달 패널 스코프에 포커스를 요청합니다. Web 은 disclosure 패널을 비모달로 포탈하며 자동으로 포커스를 옮기지 않습니다.
- 이탈 — 명시적 트리거 복귀는 없습니다. Flutter 스코프가 사라질 때의 복귀는 프레임워크가 관리합니다.
-
트랩 — Flutter 모달 패널에서는
Tab이 스코프 안에서 순환합니다. Web 은 배경에inert를 걸지 않으며 자식 링크의 문서 순서를 따릅니다. -
포커스 표시 — Flutter 트리거는 링을 끄고 focused 채움을 쓰며, Web 버튼은
focus-visible링을 유지합니다.
알려진 제약#
- Flutter 의 호버 열기도 패널 포커스를 가져갑니다. Web 의 비모달 disclosure 와 다른 경험입니다.
-
Flutter 링크는 호스트가 이동을 배선해야 합니다.
href는 링크 의미를 전달하고 실제 이동은onTap이 맡습니다. Web 은 네이티브 앵커입니다. - 랜드마크 이름 오버라이드는 Web 에만 있습니다. Flutter 는 기본 로케일 이름을 쓰므로 내비게이션이 여럿인 페이지의 구별에 제약이 있습니다.
- 포인터 이탈로 패널이 닫힙니다. 키보드로 조작하는 동안 마우스가 패널을 벗어나는 경우에도 닫힐 수 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
NavigationMenu는 Flutter와 Web이 동일한 items / activeIndex /
onActiveIndexChanged / variant / navigationMenuStyle
API와 동작을
갖습니다. 드롭다운 패널은 Flutter에서는 OverlayPortal, Web에서는 루트
OverlayHost의 menu 레이어로 portal되어 양쪽 모두 ancestor overflow에
잘리지 않습니다.
패널 전환 모션도 양쪽 동작이 같습니다 — 같은 panelSwitchDuration 과
CoreEasing.move 를 읽고, 같은 방향으로 콘텐츠가 교차합니다. 전달
수단만 렌더 엔진을 따릅니다: Flutter 는 보간(TweenAnimationBuilder +
Transform.translate), Web 은 CSS 키프레임(co-overlay-switch
/
co-overlay-swap-*)입니다. Web 이 transition 대신 키프레임을 쓰는 이유는
포털이 패널 DOM 노드를 매 렌더 재생성해 transition 이 발화하지 않기
때문입니다.
좁은 컨테이너 (의도된 미해소) — 컨테이너가 트리거 바의 intrinsic 폭보다
좁으면 두 플랫폼이 다르게 깨집니다. Flutter 는 RenderFlex overflowed 경고와
오버플로 스트라이프를 그리고, Web 은 아무 신호 없이 테두리 밖으로
흘러넘칩니다. 가로 스크롤·clip 으로 맞추지 않고 이 상태를 유지하기로
했으므로, 트리거가 모두 들어가는 폭에서만 사용하세요.
관련 컴포넌트 (Related Components)#
- Menubar: 데스크탑 앱 스타일의 메뉴 바
- Menu: 사이드바 형태의 일반 메뉴
- Navigation: 페이지 이동을 위한 네비게이션 바