Skip to main content
iOS / SwiftUI · WCAG 2.2 AA

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 Menu with Button actions.
  • 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 Menu so 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 Button actions, each with a clear, specific label that reads well out of context.
  • Give a destructive command role: .destructive so the system presents it as destructive, rather than signaling it with red text alone (see global.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 VStack or conditional overlay of buttons; it does not receive VoiceOver focus when shown and is not announced as a pop-up button. Use the native Menu.
  • 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 native Menu does 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 Image only; it disappears at the largest Dynamic Type sizes. Use a Picker inside the Menu for a checked choice, whose checkmark stays visible.
  • Do not rely on Section header 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 Picker for a checked sub-choice; its selection checkmark stays visible at all Dynamic Type sizes, unlike a manually drawn trailing checkmark Image.

Golden Pattern

The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.

SWIFT
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 .accessibilityLabel on 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 Image used to mark a checked command disappears at the largest sizes (an Apple platform defect); prefer a Picker inside the menu.

Visual

  • Destructive command text and any Section header 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.