---
title: NakedMenu
description: Headless menu component with keyboard navigation and accessibility
keywords: [flutter, menu, dropdown, context-menu, overlay, headless]
---

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

<Info>
  Browse the full sample at [`packages/example/lib/api/naked_menu.0.dart`](https://github.com/btwld/naked_ui/blob/main/packages/example/lib/api/naked_menu.0.dart).
</Info>

## Basic implementation

```dart
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: Text(label)),
              if (selected)
                const Icon(Icons.check, size: 16, color: Colors.blue),
            ],
          ),
        );
      },
    );
  }
}
```

## State Snapshots

### Trigger – `NakedMenuState`
- `isOpen` → whether the overlay is visible
- `widgetStates` → 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 model
- `widgetStates` → hover/focus/pressed/disabled states for styling

### Access from Context

- `NakedMenuState.of(context)` / `maybeOf(context)` → retrieve the trigger state
- `NakedMenuItemState.of<T>(context)` / `maybeOf<T>(context)` → read the nearest item state
- `NakedMenuState.controllerOf(context)` / `maybeControllerOf(context)` → access the shared `WidgetStatesController`

## NakedMenu Constructor

```dart
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(),
  this.semanticLabel,
  this.excludeSemantics = false,
})
```

### Key Parameters

- `child` / `builder` → supply a static trigger child or a dynamic `ValueWidgetBuilder<NakedMenuState>`
- `overlayBuilder` → `(BuildContext, RawMenuAnchorOverlayInfo)` from `RawMenuAnchor`
- `controller` → `MenuController` controlling show/hide
- `onSelected` → callback invoked with the selected value; the controller also exposes `select`
- `positioning` → provide an `OverlayPositionConfig` for alignment or offset tweaks
- `closeOnClickOutside` → hide the overlay when tapping outside (default `true`)
- `consumeOutsideTaps` / `useRootOverlay` → capture outside taps before they reach ancestors or target the root overlay stack
- `triggerFocusNode` → reuse an external focus node so focus returns to the trigger after closing
- `semanticLabel` → provide an accessible name for the menu trigger
- `excludeSemantics` → hide the menu trigger subtree from the semantic tree; opened overlay content remains exposed
- Lifecycle hooks: `onOpen`, `onClose`, `onCanceled` announce overlay visibility; `onOpenRequested`, `onCloseRequested` let 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.

```dart
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.isHovered` and `itemState.isFocused`
- Restoring focus: pass a `triggerFocusNode` so focus returns to the trigger after closing
- Arrow keys work automatically; ensure your overlay contains focusable descendants
