NakedMenu
Headless menu component with keyboard navigation and accessibility
Headless menu component. Handles focus, keyboard navigation, outside-click dismissal, and accessibility. Use builder pattern for custom styling.
When to use this
- Context menus: Right-click or long-press menus with actions
- Dropdown actions: Button that reveals multiple action options
- Navigation menus: Mobile-style slide-out or dropdown navigation
- Custom overlays: Any triggered overlay that needs menu-like behavior
Browse the full sample at example/lib/api/naked_menu.0.dart.
Basic implementation
import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';
class MenuExample extends StatefulWidget {
const MenuExample({super.key});
@override
State<MenuExample> createState() => _MenuExampleState();
}
class _MenuExampleState extends State<MenuExample> {
final controller = MenuController();
String? _selection;
@override
Widget build(BuildContext context) {
return NakedMenu<String>(
controller: controller,
builder: (context, state, _) {
final bool isOpen = state.isOpen;
final bool isHovered = state.isHovered;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: isOpen || isHovered ? Colors.grey.shade100 : Colors.white,
border: Border.all(
color: isOpen || isHovered ? Colors.grey.shade400 : Colors.grey.shade300,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_selection ?? 'Actions'),
const SizedBox(width: 8),
Icon(isOpen ? Icons.expand_less : Icons.expand_more, size: 16),
],
),
);
},
overlayBuilder: (context, info) {
return Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(vertical: 6),
constraints: BoxConstraints(
minWidth: info.anchorRect.width,
maxWidth: 220,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_MenuItem(
value: 'edit',
icon: Icons.edit,
label: 'Edit',
selectedValue: _selection,
),
_MenuItem(
value: 'duplicate',
icon: Icons.copy,
label: 'Duplicate',
selectedValue: _selection,
),
const Divider(height: 1),
_MenuItem(
value: 'delete',
icon: Icons.delete,
label: 'Delete',
selectedValue: _selection,
),
],
),
);
},
onSelected: (value) => setState(() => _selection = value),
);
}
}
class _MenuItem extends StatelessWidget {
const _MenuItem({
required this.value,
required this.icon,
required this.label,
required this.selectedValue,
});
final String value;
final IconData icon;
final String label;
final String? selectedValue;
@override
Widget build(BuildContext context) {
return NakedMenuItem<String>(
value: value,
builder: (context, itemState, _) {
final bool isHovered = itemState.isHovered;
final bool selected = selectedValue == value;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
color: isHovered ? Colors.grey.shade100 : null,
child: Row(
children: [
Icon(icon, size: 16,
color: selected ? Colors.blue : Colors.grey.shade700),
const SizedBox(width: 12),
Expanded(child: const Text(label)),
if (selected)
const Icon(Icons.check, size: 16, color: Colors.blue),
],
),
);
},
);
}
}
Trigger – NakedMenuState
isOpen→ whether the overlay is visiblewidgetStates→ hover/focus/pressed/disabled states exposed in the builder (state.isHovered,state.isPressed, etc.)
Items – NakedMenuItemState<T>
value→ the item's value for comparisons with your own selection modelwidgetStates→ hover/focus/pressed/disabled states for styling
Access from Context
NakedMenuState.of(context)/maybeOf(context)→ retrieve the trigger stateNakedMenuItemState.of<T>(context)/maybeOf<T>(context)→ read the nearest item stateNakedMenuState.controllerOf(context)/maybeControllerOf(context)→ access the sharedWidgetStatesController
NakedMenu Constructor
const NakedMenu({
Key? key,
this.child,
this.builder,
required this.overlayBuilder,
required this.controller,
this.onSelected,
this.onOpen,
this.onClose,
this.onCanceled,
this.onOpenRequested,
this.onCloseRequested,
this.consumeOutsideTaps = true,
this.useRootOverlay = false,
this.closeOnClickOutside = true,
this.triggerFocusNode,
this.positioning = const OverlayPositionConfig(),
})
Key Parameters
child/builder→ supply a static trigger child or a dynamicValueWidgetBuilder<NakedMenuState>overlayBuilder→(BuildContext, RawMenuAnchorOverlayInfo)fromRawMenuAnchorcontroller→MenuControllercontrolling show/hideonSelected→ callback invoked with the selected value; the controller also exposesselectpositioning→ provide anOverlayPositionConfigfor alignment or offset tweakscloseOnClickOutside→ hide the overlay when tapping outside (defaulttrue)consumeOutsideTaps/useRootOverlay→ capture outside taps before they reach ancestors or target the root overlay stacktriggerFocusNode→ reuse an external focus node so focus returns to the trigger after closing- Lifecycle hooks:
onOpen,onClose,onCanceledannounce overlay visibility;onOpenRequested,onCloseRequestedlet you intercept RawMenuAnchor requests before they commit
NakedMenuItem Essentials
NakedMenuItem<T> extends the shared OverlayItem base. Provide either child or a builder; the builder gets a NakedMenuItemState<T> typed snapshot. NakedMenu.Item is exported as a convenience alias for the constructor.
const NakedMenuItem({
Key? key,
required T value,
Widget? child,
ValueWidgetBuilder<NakedMenuItemState<T>>? builder,
bool enabled = true,
String? semanticLabel,
bool closeOnActivate = true,
})
Set closeOnActivate to false for menus that keep focus while toggling options.
Accessibility Tips
- Always expose a textual label on the trigger and/or provide
semanticLabel - Menu items should stay at least 44×44 logical pixels for touch targets
- Provide visual hover/focus states using
itemState.isHoveredanditemState.isFocused - Restoring focus: pass a
triggerFocusNodeso focus returns to the trigger after closing - Arrow keys work automatically; ensure your overlay contains focusable descendants