Forms in iOS native apps

What will we cover?

Part 1. Screen reader modes on the web

On desktop web, screen readers like NVDA and JAWS operate in two modes:

Browse mode

Lets users read through the whole page, line by line and element by element.

Forms mode (JAWS)/ Focus mode (NVDA)

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>

Part 2. Why mobile feels different

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.

Example 1: Touch exploration

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.

Example 2: Swiping into a field with an error message below it.

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.”

Part 3. Web vs iOS: shifting your mental model

1. No structural hierarchy

In HTML, there is a strong hierarchy of information.

Elements combine to form a meaningful, navigable document outline.

For example:
  • Headings form a nested tree.
  • Landmarks define regions of the page.
  • Lists introduce grouping and order.
  • Tables provide grid relationships.

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.

2. Limited semantics

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:

Examples of built-in control semantics:
  • TextField – exposes the text field trait automatically
  • Button – has the button trait automatically
  • Toggle – is a switch with on/off state and role
  • SecureField – is automatically a secure text entry

3. No form structure

On the web, HTML provides three levels of form structure:

Level 1: Form controls

The elements users interact with (input, textarea, select, checkbox, etc.)

Level 2: Form fields

A combination of:

  • The label
  • The control
  • Instructions (optional)
  • Required indicators (optional)
  • Error messages (optional)
Level 3: Form groups

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.).

But:
  • There is no “form field” concept.
  • There is no container that binds label + control + instructions + error.
  • There is no <fieldset> or semantic form grouping.
Developers must manually assemble the entire experience:
  • A Text view (visible label)
  • A TextField or other control
  • A Text view for instructions or errors
  • Optional grouping using accessibility containers

4. No semantic <label> element

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.

There is only:
  • Visual text (Text).
  • Interactive controls (TextField, SecureField, etc).
Unlike the web:
  • You cannot bind a visual Text element to a form control.
  • There is no equivalent of <label for="id">.
  • iOS does not infer any relationship between a Text and a TextField.

This means all accessibility information must be applied directly to the control itself, including:

  • Accessible name
  • Required state
  • Instructions
  • Error messages

5. No semantic states

Another major difference between the web and iOS is how form field states are handled.

On the web: states are semantic. For example:

  • Error state: aria-invalid="true"
  • Required state: 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:

  • “invalid”
  • “required”

There is no built-in way to declare a field as required or invalid using accessibility APIs.

Instead, everything must be expressed through text.

  • Error messages: “Error: You must use a valid email address.”
  • Required fields: “Email, required.”

Part 4. SwiftUI language basics

Before we look at some form examples, here is a breakdown of SwiftUI structure.

Why SwiftUI and not UIKit?

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.

Why not JetPack Compose?

We could look at this in a future session if people are interested - so much more powerful and “accessible”.

Not Java or Kotlin

Example 1: A heading

// 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)
SwiftUI visual styles - not semantic:
  • Large titles: .largeTitle, .title, .title2, .title3
  • Headlines: .headline, .subheadline
  • Body styles: .body, .callout, .caption, .caption2, .footnote
SwiftUI heading semantics:

All we right now is .accessibilityAddTraits(.isHeader)

Example 2: A 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)

The importance of @State

This “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.

This stored information can include:
  • Text typed by a user.
  • Whether a toggle is on/off.
  • Whether a menu is open or closed.
  • Which tab is selected.
  • What page you're on.

@State is very important from an accessibility perspective.

On the web, the DOM stores each field’s value.

When a user types into a form control:
  • The DOM input value is updated.
  • The accessibility tree value is updated.
  • Screen readers announce the correct value when focus returns.

In SwiftUI, the @State variable stores the field’s value.

When a user types into a TextField:
  • The @State variable is updated.
  • SwiftUI re-runs the view and updates only the parts that changed.
  • SwiftUI reconstructs accessibility information as part of its view update.
  • VoiceOver announces the updated value when focus returns.

Part 5. Adding accessible names

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.

Method 1. Use 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.

  • Placeholder stops being visible once the user types, which makes behaviour unpredictable.
  • VoiceOver sometimes uses placeholder as editing label, which is announced differently when editing vs when entering.
  • Placeholder is not read in all rotor modes.

Method 2. Use 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:

  • The accessible name (from accessibilityLabel)
  • The editing label (derived from the 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 other advanced methods

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.

Part 6. Adding instructions and error messages

We’ll look at three approaches to associating instructions and errors with form fields.

Method 1: accessibilityLabel

Sadly, 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")
Pros:
  • Easy to implement
  • Very robust.
Cons:
  • Can create a long accessible name
  • Yes, it’s ugly.
  • Yes, it’s verbose.
  • Yes, it feels wrong.
  • But it is the only 100% reliable method.

Method 2: accessibilityHint

accessibilityHint allows you to attach a short description to the control.

It was originally designed for interaction guidance, not form-related instructions or errors.

Examples of interaction guidance
  • “Swipe left to reveal more items”
  • “Double tap to enlarge”

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")
Pros:
  • Easy to implement
  • Reads automatically when the field is focused if “Speak Hints” is on.
Cons:
  • Can be turned off (“Speak Hints”)
  • Intended for interaction guidance, not business logic
  • Not recommended as the only place for required information or errors

Method 3: UIAccessibilityCustomContent

UIAccessibilityCustomContent 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"
  )
Pros:
  • Structured key/value pairs
  • Supports multiple types of information
  • More flexible than a single hint
Cons:
  • By default, this content is only available through the rotor
  • Not all users know how to use the rotor
  • Not guaranteed to be announced when the field receives focus

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.

Issues with using .high
  • .high is still ignored on some iOS 15–17 devices.
  • When hints are off, some versions of iOS treat .high like a hint even though it is not one.
  • If the field already has a hint, custom content sometimes disappears entirely.

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.

Which of these methods do you prefer?
  • Method 1: accessibilityLabel
  • Method 2: accessibilityHint
  • Method 3: UIAccessibilityCustomContent

SwiftUI accessibility modifier list

Naming
  • .accessibilityLabel: Sets the element’s spoken name.
  • .accessibilityValue: Announces the current value.
  • .accessibilityHint: Adds extra guidance.
  • .accessibilityFootnote: Adds secondary explanatory text.
Visibility & structure
  • .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.
Traits (roles & characteristics)
  • .accessibilityAddTraits: Adds traits (button, selected, etc.).
  • .accessibilityRemoveTraits: Removes traits (e.g. take away “button”).
Ordering & navigation
  • .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.
Actions
  • .accessibilityAction: Adds a custom action.
  • .accessibilityAction(named:): Adds a labelled custom action.
  • .accessibilityAdjustableAction: Handles increment/decrement actions (like sliders).
Rewrite semantics (use with caution)
  • .accessibilityRepresentation: Replaces the view’s entire accessibility identity.
Identifiers (testing only)
  • .accessibilityIdentifier: Used only for UI tests; no effect on accessibility.