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),
            ],
          ),
        );
      },
    );
  }
}

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

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 dynamic ValueWidgetBuilder<NakedMenuState>
  • overlayBuilder(BuildContext, RawMenuAnchorOverlayInfo) from RawMenuAnchor
  • controllerMenuController 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
  • 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.

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