Menu
A SwiftUI Menu that presents a pull-down list of command buttons, takes VoiceOver focus when opened, with commands carrying the destructive role where applicable.
Selection
The criteria an agent checks before retrieving this component.
Use when
- Use when a trigger reveals a pull-down list of two or more commands the user runs (e.g., "Duplicate", "Rename", "Delete"), or an overflow "more" control that groups in-place actions. Uses a SwiftUI
MenuwithButtonactions. - Use when collecting several in-place actions behind one compact control (an icon-only ellipsis or "more" button) keeps the primary UI uncluttered.
Try a different component when
- Do not use when the user is choosing one persistent value from a set rather than running a command (use
select.menu). - Do not use when a single one-time decision interrupts the task and must be acknowledged before continuing (use
dialog.confirmation). - Do not use when there is a single, always-visible action (use
button.basic).
Must Haves
Non-negotiable structure. Every generated instance must satisfy these rules.
- Use a native
Menuso the trigger exposes the pop-up button role and the menu receives VoiceOver focus when it opens (WCAG 2.4.3). - Give the trigger an accessible name. A text trigger (
Menu("Actions")) is named by its label text; an icon-only trigger (an ellipsis or "more" glyph) needs an.accessibilityLabel(e.g., "Map options"). - Populate the menu with
Buttonactions, each with a clear, specific label that reads well out of context. - Give a destructive command
role: .destructiveso the system presents it as destructive, rather than signaling it with red text alone (seeglobal.semantic-color). - Meets the touch target size baseline in
global_rules.md(global.touch-target-size). - Meets the system focus indicator baseline in
global_rules.md(global.focus-visible).
Donts
Avoid these accessibility and UX barriers.
- Do not build a faux menu from a custom
VStackor conditional overlay of buttons; it does not receive VoiceOver focus when shown and is not announced as a pop-up button. Use the nativeMenu. - Do not leave an icon-only trigger without an
.accessibilityLabel; VoiceOver then has no accessible name for it. - Do not attempt to restore VoiceOver focus to the trigger after the menu closes with
@AccessibilityFocusState. Unlike a.sheet()or.popover(), a nativeMenudoes not return focus to its trigger and cannot be forced to, an Apple platform defect. Do not paper over it with a workaround that lands focus somewhere misleading. - Do not convey a command's selected state with a trailing checkmark
Imageonly; it disappears at the largest Dynamic Type sizes. Use aPickerinside theMenufor a checked choice, whose checkmark stays visible. - Do not rely on
Sectionheader text to be exposed as a heading; native menu section headers lack the heading trait and have insufficient contrast (an Apple platform defect).
Customizable
Alternatives and options that give the AI agent some room to move.
- The trigger may be a text label (
Menu("Actions")), an icon plus text, or an icon-only control; an icon-only trigger may name itself with.accessibilityLabel. - Commands may be grouped with
Section, as long as the grouping is not the only way the structure is conveyed to VoiceOver: native menu section headers are missing the heading trait (see Don'ts). - A menu may host a
Pickerfor a checked sub-choice; its selection checkmark stays visible at all Dynamic Type sizes, unlike a manually drawn trailing checkmarkImage.
Golden Pattern
The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.
import SwiftUI
struct MenuBasicDemo: View {
var body: some View {
HStack {
// Text trigger: the "Actions" label is the accessible name.
Menu("Actions") {
Button("Duplicate") { print("Duplicate") }
Button("Rename") { print("Rename") }
// Destructive role, not red text alone.
Button("Delete", role: .destructive) { print("Delete") }
}
// Icon-only trigger needs an explicit accessible name.
Menu {
Button("Show Map") { print("Show Map") }
Button("Share") { print("Share") }
} label: {
Image(systemName: "ellipsis")
}
.accessibilityLabel("Map options")
}
// A native Menu takes VoiceOver focus when opened. On close it does NOT
// return focus to the trigger, and @AccessibilityFocusState cannot restore
// it, an Apple platform defect, unlike .sheet() or .popover().
}
}
Acceptance Checks
The component’s test spec — an optional body of checks for verification.
Traits & semantics
- The trigger exposes the pop-up button role together with its accessible name (the label text, or the
.accessibilityLabelon an icon-only trigger). - Section headers, when used, are not exposed with the heading trait (an Apple gap); do not depend on a heading trait to convey menu structure.
VoiceOver
- The trigger speaks its name and pop-up button role (e.g., "Actions, Button, Pop Up Button").
- Opening the menu moves VoiceOver focus into the menu, and each command is announced.
- Each command speaks its specific label; a destructive command is presented as destructive.
- When the menu closes, VoiceOver focus does not return to the trigger (an Apple platform defect); this is a known limitation of
Menu, not a per-app bug.
Switch Control & Full Keyboard Access
- The trigger is reachable and operable via Switch Control and a hardware keyboard: the menu opens, a command can be activated, and the menu closes.
Dynamic Type
- The trigger label and command labels scale with Dynamic Type and stay fully visible at accessibility text sizes. A trailing checkmark
Imageused to mark a checked command disappears at the largest sizes (an Apple platform defect); prefer aPickerinside the menu.
Visual
- Destructive command text and any
Sectionheader text can fall short on contrast (an Apple platform defect); verify against your target appearances, and do not rely on those defaults alone to convey meaning.