List Row (Navigable)
A NavigationLink rendered as a row in a List, where the whole row is one target that drills into another in-app screen and reads as a button to VoiceOver, with a decorative trailing disclosure chevron.
Selection
The criteria an agent checks before retrieving this component.
Use when
- Use when tapping a row pushes another in-app screen and the whole row is the target (e.g., a "Settings" row that opens the Settings detail, a message that opens a thread). Uses a
NavigationLinkinside aList, so the row exposes the button trait and the system supplies the disclosure chevron. - Use when a scrolling set of such rows forms a navigable list (a settings list, an inbox, a menu of sections).
Try a different component when
- Do not use when the control performs an in-place action and stays on the current screen (use
button.basic). - Do not use when the control opens a URL or leaves the app (use
link.basic). - Do not use when the row is a static, non-navigating list item (use
list.basic).
Must Haves
Non-negotiable structure. Every generated instance must satisfy these rules.
- Use a
NavigationLinkinside aList(orForm) so the whole row is a single accessible element with the button trait, and the system renders the decorative trailing disclosure chevron. - The row has an accessible name describing its destination, taken from the row's visible label. When the row shows a title plus secondary text (subtitle or trailing value), they read together as one name.
- The row reads as a button, not a link.
NavigationLinkcarries the button trait, not.isLink. Do not describe an in-app drill-in row as a "link". - Keep the disclosure chevron decorative; do not give it a separate accessible element or label. Inside a
List,NavigationLinksupplies and hides it automatically. - Follow the push and pop focus contract in
global_rules.md(global.navigation-focus): the destination sets anavigationTitle, the system manages VoiceOver focus on push, and focus returns to this row on pop. - 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 drill-in row from
Textor anHStackwith an.onTapGesture; it exposes no button trait, is unreachable and inoperable for Switch Control and Full Keyboard Access, and leaves focus unmanaged so VoiceOver can land behind the pushed screen. - Do not call a navigable row a "link" in its accessible name; VoiceOver announces it as a button, and "link" misleads users about where it goes.
- Do not add a separate accessible element or label to the disclosure chevron.
- Do not split a rich row into multiple sibling accessibility elements (title, subtitle, and chevron each focusable); combine them into one.
- Do not force VoiceOver focus to the back button or the destination title on push; let the system manage it, per
global.navigation-focus.
Customizable
Alternatives and options that give the AI agent some room to move.
- The row content may be a plain text label or a richer layout (title plus subtitle, a leading icon, a trailing value), as long as it resolves to one accessible element with a coherent name. If a custom row layout fragments into separate elements, combine them with
.accessibilityElement(children: .combine). - The destination may be supplied by the value-based
NavigationLink(value:)paired with.navigationDestination(for:), or by the closure-basedNavigationLink { } label: { }; both carry the same button semantics and system focus management. - The trailing accessory may be the default disclosure chevron or a custom trailing element, provided it stays decorative and the whole row remains the single target.
Golden Pattern
The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.
import SwiftUI
struct ListRowDemo: View {
var body: some View {
NavigationStack {
List {
// Whole row is one button-trait target; List supplies the decorative chevron.
NavigationLink {
SettingDetail(title: "Account")
} label: {
Text("Account")
}
// Rich row: title and subtitle combine into one accessible element automatically.
NavigationLink {
SettingDetail(title: "Wi-Fi")
} label: {
VStack(alignment: .leading) {
Text("Wi-Fi")
Text("Connected")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
}
}
}
struct SettingDetail: View {
let title: String
var body: some View {
Text("\(title) settings")
.navigationTitle(title) // destination has a title so the push is announced by name
}
}
Acceptance Checks
The component’s test spec — an optional body of checks for verification.
Traits & semantics
- Each row is a single accessible element carrying the button trait; the disclosure chevron is not surfaced as its own element.
- A row with a title plus secondary text reads as one element whose name includes both, not as fragmented siblings.
VoiceOver
- Each row speaks its destination name followed by "Button", never "Link".
- Double-tapping a row pushes its destination, and the pushed screen's title is announced (per
global.navigation-focus). - On returning to the list, focus is restored to the row that triggered the navigation.
Switch Control & Full Keyboard Access
- Every row is reachable and activatable via Switch Control and a hardware keyboard.
Dynamic Type
- Row labels scale with Dynamic Type and stay fully visible; rows grow in height rather than truncating at accessibility text sizes.