On desktop web, screen readers like NVDA and JAWS operate in two modes:
Lets users read through the whole page, line by line and element by element.
Triggered when entering form controls, or manually toggled.
In forms mode, keystrokes are sent directly to the focused control.
Screen readers usually only announce the control itself and any information that is programmatically associated with it.
Anything not programmatically tied to a form control can effectively become invisible while the user is in forms mode.
This is why programmatic association is non-negotiable on the web-based forms.
Without these connections, users may miss essential information!
Some common methods include:
<!-- Labels: matching for and id values -->
<label for="aaa">Email</label>
<input id="aaa" type="text">
<!-- Instructions / errors: aria-describedby -->
<label for="bbb">Email</label>
<span id="ccc">Use your full email address</span>
<input id="bbb" aria-describedby="ccc">
<!-- Invalid fields: aria-invalid="true" -->
<label for="fff">Email</label>
<input id="fff" aria-invalid="true">
<!-- Required fields: aria-required="true"
or required -->
<label for="hhh">Email</label>
<input id="hhh" required>
<!-- Radio group headings: fieldset, legend -->
<fieldset>
<legend>Do you like boats?</legend>
...
</fieldset>
Mobile screen readers (VoiceOver, TalkBack) do not have forms mode.
Users can touch any element on the screen to hear it announced.
They can also swipe through all focusable items on the screen.
This includes labels, instructions and error messages if those items are exposed to accessibility.
This means users are likely to encounter text nearby form controls, even if they’re not not formally associated.
This creates the illusion that “mobile forms don’t really need programmatic association.”
But that illusion is misleading.
Let’s look at some examples to help explain why programmatic association is still essential on mobile.
A screen reader user taps directly on a form field that has a visible label and instructions.
Only programmatically attached information is announced.
In the worst case, if the label and instructions aren’t programmatically associated, they will get:
“Text field.”
If the label is programmatically associated but the instructions are not, they will get:
“Phone number. Text field.”
If the label and instructions are programmatically associated, they will get:
“Phone number. Include your area code. Text field.”
Users could swipe up or down to find out more, but they should not have to.
Touch exploration is incredibly common — and it means that we must prgrammatically associated key info.
Imagine an error message positioned after a field but it has not been programmatically associated.
This time, the screen reader user is swiping through each item on the screen.
They may hear the following:
Swipe 1 - Text label: “Email.”
Swipe 2 - Form field: “Text field.”
They may even hear the following if the label has been associated:
Swipe 1 - Text label: “Email.”
Swipe 2 - Form field: “Email. Text field.”
But they will not be aware that there is an error message directly below the field.
So, they will not know that the field is currently in a state of error.
And, they will not have any information that may help them resolve the error.
Ideally, they should hear the following when swiping to the field:
“Email. Error: Must be a valid email address. Text field.”
In HTML, there is a strong hierarchy of information.
Elements combine to form a meaningful, navigable document outline.
The DOM provides the structural hierarchy, and the accessibility tree is generated directly from that structure.
In iOS, there is no inherent hierarchy of information.
VoiceOver receives a flat list of elements unless you explicitly group them or control their reading order.
Every HTML element has built-in semantics.
Each element carries a native role and purpose that browsers and assistive technologies understand.
iOS does not create semantics for layouts and relationships (no headings, no grouping, no document outline).
However, controls do have built-in semantics:
TextField – exposes the text field trait automaticallyButton – has the button trait automaticallyToggle – is a switch with on/off state and roleSecureField – is automatically a secure text entryOn the web, HTML provides three levels of form structure:
The elements users interact with (input, textarea, select, checkbox, etc.)
A combination of:
Sets of related fields grouped under a shared heading, announced together by screen readers via <fieldset> and <legend>.
In iOS only the lowest-level concept exists — controls (TextField, Button, Picker, Toggle, etc.).
<fieldset> or semantic form grouping.In the web world, we have the <label> element, which is used with form fields to provide a programmatically associated label.
In SwiftUI/iOS, there is no semantic <label> element.
Text).TextField, SecureField, etc).Text element to a form control.<label for="id">.Text and a TextField.This means all accessibility information must be applied directly to the control itself, including:
Another major difference between the web and iOS is how form field states are handled.
On the web: states are semantic. For example:
aria-invalid="true"required or aria-required="true"These states are semantic, machine-readable, and appear as distinct items in the accessibility tree.
In iOS/SwiftUI: states are not semantic.
iOS has no equivalent properties for:
There is no built-in way to declare a field as required or invalid using accessibility APIs.
Instead, everything must be expressed through text.
Before we look at some form examples, here is a breakdown of SwiftUI structure.
Because the code is less intimidating that UIKit.
But it is important to understand that SwiftUI is a declarative front-end on top of UIKit.
We could look at this in a future session if people are interested - so much more powerful and “accessible”.
Not Java or Kotlin
// View: Like an HTML element
Text("Testing forms")
.font(.largeTitle.weight(.bold))
.accessibilityAddTraits(.isHeader)
.padding(.bottom, 8)
// View type: Like an HTML role
Text("Testing forms")
.font(.largeTitle.weight(.bold))
.accessibilityAddTraits(.isHeader)
.padding(.bottom, 8)
// Initialiser argument: What the view displays
Text("Testing forms")
.font(.largeTitle.weight(.bold))
.accessibilityAddTraits(.isHeader)
.padding(.bottom, 8)
// Modifiers: Like CSS + ARIA attributes
Text("Testing forms")
.font(.largeTitle.weight(.bold))
.accessibilityAddTraits(.isHeader)
.padding(.bottom, 8)
// Modifier parameters: like CSS property values
or aria attribute values
Text("Testing forms")
.font(.largeTitle.weight(.bold))
.accessibilityAddTraits(.isHeader)
.padding(.bottom, 8)
.largeTitle, .title, .title2, .title3.headline, .subheadline.body, .callout, .caption, .caption2, .footnoteAll we right now is .accessibilityAddTraits(.isHeader)
TextField// View: Like a text field element
TextField("Phone", text: $phone)
// View type: Like the role of input
TextField("Phone", text: $phone)
// Initialiser arguments: placeholder + binding
TextField("Phone", text: $phone)
// Placeholder text: Shown when the field is empty
TextField("Phone", text: $phone)
// Binding: Updates the variable as the user types
TextField("Phone", text: $phone)
@StateThis “binding” ($phone) connects the control to a @State. For example:
// @State property
@State private var phone = ""
// Textfield control
TextField("Phone", text: $phone)
@State is how SwiftUI lets the app store information that can change while the user is interacting with the view.
@State is very important from an accessibility perspective.
On the web, the DOM stores each field’s value.
In SwiftUI, the @State variable stores the field’s value.
@State variable is updated.Let’s start with a visible text label above a form field.
// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("Phone", text: $phone)
We can add an accessible name to a TextField in two ways.
placeholder
// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("Phone", text: $phone)
While the placeholder can be used to create an accessible name, it is not recommended.
accessibilityLabel// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone")
This method can also be used to add additional information such as “required”.
// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
SwiftUI exposes two separate concepts to VoiceOver:
accessibilityLabel)placeholder)So, the placeholder is still used internally by SwiftUI as the TextField’s editing label, even when you override the accessible name.
In some cases, the placeholder value and accessibilityLabel value are both announced when the field receives focus.
The most robust method of appling an accessible name to controls is to use the .accessibilityLabel and with the placeholder set to "".
// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
Two very advanced techniques exist in SwiftUI for creating accessible names and combining content - but they are not recommended:
accessibilityRepresentation { }
Allows you to replace the entire accessibility object with a custom one.
accessibilityElement(children: .ignore)
Tells VoiceOver to ignore everything inside a view and treat the parent as one combined element.
These techniques give developers total control over what VoiceOver reads.
They are not recommended or needed for standard form fields.
We’ll look at three approaches to associating instructions and errors with form fields.
accessibilityLabelSadly, the most robust method is to add instructions or errors directly into the accessibilityLabel.
With this method, you can add the instructions or errors to the accessible name:
// Visible text label
Text("Phone")
.font(.headline)
// Visible instructions
Text("Include your area code")
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone, Include your area code")
accessibilityHintaccessibilityHint allows you to attach a short description to the control.
It was originally designed for interaction guidance, not form-related instructions or errors.
But accessibilityHint is often used to attach simple instructions.
// Visible text label
Text("Phone")
.font(.headline)
// Visible instructions
Text("Include your area code")
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
.accessibilityHint("Include your area code")
UIAccessibilityCustomContentUIAccessibilityCustomContent allows you to attach structured metadata (key–value) to a control.
// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
.accessibilityCustomContent(
"Error", "Must include your area code"
)
// Visible error message
Text("Error: Must include your area code")
// Key
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
.accessibilityCustomContent(
"Error", "Must include your area code"
)
// Value
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
.accessibilityCustomContent(
"Error", "Must include your area code"
)
You can also set UIAccessibilityCustomContent with importance: .high.
The importance: .high promotes this content into the main VoiceOver announcement, while preserving its key–value structure.
This means that both the key and value are announced together.
More importantly, this content is available without needing the rotor.
// Visible text label
Text("Phone")
.font(.headline)
// Form field
TextField("", text: $phone)
.accessibilityLabel("Phone, required")
.accessibilityCustomContent(
"Error", "Must include your area code"
importance: .high
)
// Visible error message
Text("Error: Must include your area code")
There are some possible exceptions where high-importance content may not be included.
.high is still ignored on some iOS 15–17 devices..high like a hint even though it is not one.There are more complex techniques developers can use to build fully custom accessible components, especially in UIKit.
For now, we have focussed on SwiftUI’s standard accessibility tools.
Let’s look at a quick demo.
accessibilityLabelaccessibilityHintUIAccessibilityCustomContent.accessibilityLabel: Sets the element’s spoken name..accessibilityValue: Announces the current value..accessibilityHint: Adds extra guidance..accessibilityFootnote: Adds secondary explanatory text..accessibilityHidden: Hides the element from accessibility..accessibilityElement(children:): Controls how child views appear (include them, combine them, or ignore them)..accessibilityElement(_:): Marks a view as a single accessibility element..accessibilityRespondsToUserInteraction: Controls whether the element is focusable..accessibilityAddTraits: Adds traits (button, selected, etc.)..accessibilityRemoveTraits: Removes traits (e.g. take away “button”)..accessibilitySortPriority: Changes the reading/focus order..accessibilityTraversalAction: Custom behaviour for next/previous focus..accessibilityScrollAction — Custom behaviour when user scrolls via VoiceOver..accessibilityZoomAction: Custom behaviour when user performs zoom gestures..accessibilityAction: Adds a custom action..accessibilityAction(named:): Adds a labelled custom action..accessibilityAdjustableAction: Handles increment/decrement actions (like sliders)..accessibilityRepresentation: Replaces the view’s entire accessibility identity..accessibilityIdentifier: Used only for UI tests; no effect on accessibility.