AppKit Custom Accessibility: Roles, Actions, and Virtual Elements
Expose custom AppKit controls to VoiceOver with role protocols, stable hierarchy, accurate labels and values, actions, geometry, and notifications.
An AppKit control can look polished and respond perfectly to a mouse while remaining invisible or confusing to VoiceOver and other assistive apps. Accessibility is not a second set of labels attached at the end. It is a semantic representation of the control: what role it has, what it is called, what value and state it exposes, what action can be performed, and where the element sits in an ordered hierarchy.
Start by using a standard AppKit control when its behavior fits. Standard controls already implement much of their accessibility contract. A custom NSView is justified when the interaction or rendering cannot be expressed by a standard control. In that case, adopt the role-specific NSAccessibility protocol that matches the user’s mental model rather than exposing an arbitrary drawing surface as a generic object.
Choose one accessible element for each meaningful interaction
The visual tree and accessibility tree need not have a one-to-one relationship. A custom-drawn chart may be one visual view but contain several data points users need to inspect. Conversely, a cluster of decorative subviews may be best exposed as one meaningful group. Decide which parts are focusable, what order users should navigate, and whether each element has an independent action or value.
For an NSView subclass that behaves like a button, adopt NSAccessibilityButton and implement its required label and press action. The protocol lets the system infer the element’s role and accessibility participation. Do not implement a button role for a control that actually selects an item, edits a value, or opens a menu. The role controls how assistive technology announces and interacts with the element.
import AppKit
final class StarButton: NSView, NSAccessibilityButton {
var isStarred = false
var onPress: (() -> Void)?
override func accessibilityLabel() -> String? {
NSLocalizedString("Favorite", comment: "Custom favorite control label")
}
override func accessibilityPerformPress() -> Bool {
isStarred.toggle()
onPress?()
needsDisplay = true
return true
}
}
The sample focuses on the required button label and action. A production control must also expose dynamic state such as whether the favorite is selected, update the accessible value or selected state as appropriate, support the same operation from pointer and keyboard input, and notify assistive clients when relevant state changes. Keep the domain action in one shared method so the mouse, keyboard, and accessibility path do not drift.
Supply truthful properties, not duplicated visual text
Accessibility labels should name the element’s purpose, not repeat its role. Apple recommends concise localized labels, for example “Play” rather than “Play button.” A value describes mutable state; a help string can explain context that is not obvious. Avoid stuffing the entire screen’s content into a label. Users need an element that can be navigated and understood in context, not a long announcement on every focus change.
Expose only information that is current and meaningful. A slider needs a value and useful bounds; a toggle needs its current on/off state; a text field needs its actual editable content and selection semantics. If a custom control is read-only, do not implement a setter or action that implies users can change it. If a value changes because of background work, update the accessibility properties on the same state transition as the visual state.
Property getters are often safer than maintaining a second cache of accessible values. AppKit can ask for dynamic values when assistive apps need them. If a getter derives a value, make it inexpensive and avoid network calls, file I/O, or main-thread blocking. The accessibility representation is a client-facing API and needs the same performance discipline as drawing.
Represent non-view objects with NSAccessibilityElement
When one view draws several independently meaningful objects, create NSAccessibilityElement instances and return them from the parent view’s accessibility children. Each virtual element should have a role, concise label, parent, frame, and appropriate action or value. The hierarchy should reflect navigation order, not accidental paint order. Update or remove virtual elements when the underlying model changes.
An element’s geometry must move with its parent. Apple documents that accessibilityFrameInParentSpace is required so a custom element tracks its parent when the view moves. If you calculate a frame in screen coordinates and also supply a parent-relative frame, test both across window movement, scrolling, display changes, and zooming. Stale geometry sends the VoiceOver cursor to the wrong place even if the label is correct.
Avoid creating a new accessibility element on every getter call without a stable ownership model. Cache or reconcile elements by durable model identity for the duration of the UI state, then update their properties. Replacing every child during a minor redraw can disrupt focus and makes announcements unpredictable. A stable identity lets assistive clients understand that an item changed instead of disappearing and being recreated.
Actions, notifications, and focus
An accessibility action should invoke the same semantic command as the visible interaction. For a button, accessibilityPerformPress() should return whether it handled the request. If the action is currently unavailable, expose a disabled state and do not claim success. Do not fake interaction by synthesizing a mouse event at screen coordinates; route the operation to the model or controller action.
AppKit standard controls emit appropriate notifications for their standard behaviors. Custom controls, and standard controls used in unusual ways, may need to post notifications when value, selection, layout, or focus changes. Send a notification because a meaningful accessibility property changed, not on every animation frame or drawing pass. Excessive notifications make assistive clients do redundant work and can produce noisy announcements.
For a virtualized collection, keep the accessible hierarchy in sync with visible and navigable data. When rows load asynchronously, use a loading element or a clear result state rather than leaving a focused element with no value. If the focused item is removed, move focus deliberately to a nearby meaningful element. Do not silently reset the hierarchy to the beginning after every diffable snapshot; preserve selected and focused identity where the interaction calls for it.
Keyboard support is part of the control contract
VoiceOver actions do not replace keyboard interaction. A custom button should participate in expected keyboard focus and activation. A slider or segmented control needs keyboard behavior that exposes the same state transitions. Ensure focus rings, key-view order, and accessibility navigation all lead to a coherent interaction. If the control contains editable text, use the text system rather than inventing an incomplete text-input implementation.
The AppKit responder chain determines where actions and key events go, while the accessibility tree describes how assistive apps inspect and operate controls. These systems intersect but are not interchangeable. A command that works by responder-chain routing can still be missing from the accessibility API, and a control discoverable by VoiceOver can still have broken keyboard focus if its view does not join the key-view loop correctly.
Test with actual assistive clients
Use Accessibility Inspector during development to inspect role, label, value, actions, and frame. Then operate the workflow with VoiceOver. Inspector snapshots can reveal missing properties, but they cannot prove that navigation order, announcement timing, focus restoration, or action results are understandable. Test the complete sequence: open the view, reach the custom control by keyboard and VoiceOver, invoke its action, observe the announced state change, and continue to the next element.
Include reduced content size, localization, right-to-left text, high contrast, and dynamic data changes. Ensure text labels are localized and not clipped visually; accessibility clients can read a label that the sighted user cannot see, but that does not make a broken visual layout acceptable. Test a control after its containing view scrolls, resizes, and moves between windows.
Production checks and common mistakes
Audit each custom element for role, localized label, current value, enabled state, action, parent, navigation order, and correct screen geometry. Compare the accessible tree before and after a model update. Record the action result in a test assertion and verify that the visual and accessible states converge.
Common failures include exposing an entire custom canvas as one unlabeled element; implementing a role but omitting the role-specific required protocol methods; adding the word “button” to every button label; hard-coding labels in one language; returning a stale value; and changing drawn state without a notification when one is needed. Another failure is adopting NSAccessibilityProtocol directly on a custom NSView instead of the role-specific protocol Apple recommends. Follow compiler warnings for required methods, then test runtime behavior.
Accessibility should not disclose hidden sensitive data just because a UI can technically expose it. Expose the information needed to operate the control and understand its current result. Keep diagnostic logs free of user content and do not use assistive APIs as an excuse to add background observation of other applications.
Acceptance criteria
For every custom control, verify that VoiceOver announces a concise name, appropriate role, current state, and available action; that the action reaches the same domain behavior as a click or key press; and that focus remains stable after updates. Assert virtual element parentage and geometry after scrolling and window movement. Run keyboard-only and VoiceOver workflows against realistic data.
The accessibility API is a semantic contract between your app and assistive clients. A correct role is only the beginning. Stable identity, meaningful state, working actions, accurate geometry, and tested navigation make a custom AppKit interface operable rather than merely visible.
Related:
- AppKit Responder Chains: First Responder, Actions, and Keyboard Routing
- NSMenuItem Validation on macOS: Enable Commands From Real State
Sources: