Accessibility

Updated: Sep 24, 2026
Use the toolkit components for built-in roles, tab stops, and state attributes, and supply the meaning only the application knows. Accessible names, alternative text, region labels, and heading structure remain application responsibilities. This page separates those responsibilities and defines the focus, dismissal, and motion contracts to validate.
Directional input is the primary way people drive a Meta Ray-Ban Display app, so every requirement below has to hold when the only inputs are arrow keys and an activation key.

Component responsibilities

Every interactive component is built on InteractableBase, which resolves the following on its root element.
ConcernBehavior
Role
role="button" when the control is clickable and renders as a div. An explicit role prop always wins, and a polymorphic as element keeps its own implicit role.
Tab stop
tabIndex is 0 for focusable controls, and you can pass a different value. Set focusable={false} (or interactive={false}) to take a control out of the focus order; on a div root the attribute is simply omitted, and on any polymorphic as element it renders tabIndex={-1} so a built-in tab stop goes away too.
Disabled
disabled sets aria-disabled, suppresses activation, and renders the root at --uit-disabled-opacity instead of --uit-enabled-opacity.
Value semantics
aria-valuenow, aria-valuemin, aria-valuemax, and aria-checked are forwarded to the root when you pass them.
Name plumbing
ariaLabel is a convenience alias that becomes aria-label when you have not passed aria-label directly.
Tooltips
A tooltip popup renders role="tooltip", labelled from tooltipContentDescription when you pass it, otherwise from tooltipText and tooltipMetadata joined together.
Icons
IconImage marks every icon decorative with aria-hidden, plus an empty alt on image assets. An icon never contributes an accessible name.
Menus
VerticalMenu renders role="menu" and labels itself Menu unless you pass aria-label. ContextMenu also renders role="menu", but has no default name, so pass aria-label yourself.
Route transitions
PageTransition marks every page except the topmost one aria-hidden and inert, so assistive technology and focus only ever see the incoming page.
Read InteractableBase for the full prop surface and Interactions for the state model.

Application responsibilities

Accessible names

An accessible outcome label is required for every icon-only action. Supply the result of the action rather than the glyph name.
import {Button} from '@wearables-ui-toolkit/mrbd';

<Button icon={muteIcon} ariaLabel="Mute microphone" onActivate={mute} />;
A control that already shows text does not need a redundant label — Button with a title is named by its title. Add ariaLabel only when the visible text is missing, truncated, or ambiguous out of context.
Rich tooltip content is the one case where a name is not derivable: when you pass tooltipContent instead of tooltipText, pass tooltipContentDescription too, or the popup has no accessible name.

Alternative text

Pass alt to Avatar, and the matching avatarAlt, bannerAlt, logoAlt, or headerAvatarAlt to any component that embeds an image. Use an empty string only when a parent already describes the same thing. The alternative text must describe meaningful photos rather than use a generic string such as image.
Indicators and truncated text carry description slots instead of an alt. A ListItemStatusIndicator takes contentDescription and a StatusIcon takes accessibilityContentDescription; fill them in whenever the indicator communicates state rather than decorating the row. ListItem also accepts titleContentDescription, subtitleContentDescription, and timestampContentDescription for the cases where the visible string does not read well on its own — an abbreviation, a clipped line, a relative timestamp.

Region and group labels

Label scroll regions, lists, and menus so their boundaries are announced.
import {VerticalList} from '@wearables-ui-toolkit/mrbd';

<VerticalList ariaLabel="Recent messages">{rows}</VerticalList>;
ScrollView and VerticalList take ariaLabel. ContextMenu and VerticalMenu take aria-label. Modal uses aria-label when you pass one, and otherwise derives a name from its own title, subtitle, and banner tag — pass one explicitly whenever that derived string would not stand on its own.

Heading structure

TextView accepts an as prop, so the rendered element and the visual appearance are chosen independently. Keep the document outline correct even when a smaller appearance looks better.
import {TextStyle, TextView} from '@wearables-ui-toolkit/mrbd';

<TextView as="h2" textStyle={TextStyle.BODY2_EMPHASIZED}>
  Today
</TextView>;
as accepts 'span', 'p', 'div', 'h1' through 'h6', and 'label'.

State that is not color

The display is additive, so color separation is weaker than on a backlit screen and colors read differently against the world behind them. Availability, success, warning, and selection must not rely on color alone. Pair the color token with text, an icon, or a value attribute so the state remains perceivable.

One semantic target per action

Keep one semantic and focus target per action. A focusable control must not contain another focusable control because nested targets create ambiguous navigation and announcements. Put the role, the checked or value state, and the tab stop on the one element that owns the action, and let the indicator inside it stay decorative.
The components already follow this rule, so composing them correctly is usually enough:
  • ListItem moves the semantics to the row. A row with a trailing switch, radio, or slider renders role="switch", role="radio", or role="slider" on the row itself, along with aria-checked or aria-valuenow/aria-valuemin/aria-valuemax, and hides the indicator inside it from the accessibility tree.
  • Switch and RadioButton are visual state indicators, not controls. Their appearance is driven entirely by props, they handle no input, and they are not tab stops, but they still report role="switch" and role="radio" with aria-checked.
  • SliderBar is display-only in the same way until you give it an onChange: by default it reports role="progressbar" with its range and takes no input. Passing onChange opts it into an interactive slider — role="slider", a tab stop, and left/right arrow stepping by incrementPercentage — so pass one only when the bar itself owns the action, never inside a row that already reports a slider role.
Use ListItem for a settings row rather than assembling one, because nesting a Switch inside your own row would announce two switches for one action. When you do need your own row, give the row the role and the state, and keep every indicator inside it — PaginationIndicator, SwipeIndicator, and the progress, volume, and zoom indicators included — out of the focus order.

Focus navigation

App installs a FocusNavigationProvider scoped to its content root, so a normal application does not create one. Render your own only when you are embedding the toolkit inside a host that owns a different container.
import {FocusNavigationProvider} from '@wearables-ui-toolkit/mrbd';

<FocusNavigationProvider containerRef={hostRef} blockTabNavigation>
  {children}
</FocusNavigationProvider>;
PropTypeDefaultDescription
children
ReactNode
—
Content the provider scopes focus queries to.
enabled
boolean
true
Turns directional navigation on or off.
containerRef
RefObject<HTMLElement \| null>
—
Existing element to scope focus queries to. When omitted the provider renders its own full-size box.
blockTabNavigation
boolean
false
Blocks browser Tab traversal so navigation is arrow-key only. Off by default so keyboard and assistive-technology users keep a working Tab order.
autoFocusFirst
boolean
false
Lets the focus coordinator focus the top-left eligible control on mount and on page change. App enables this.

Keyboard contract

KeyBehavior
Arrow keys
Move focus to the nearest candidate in that direction. Handled by the provider, which calls preventDefault().
Enter, Space
Activate the focused control. Key down enters the pressed state; key up fires onActivate and dispatches a real DOM click, so onClick runs too.
Escape
Dismisses the topmost transient surface. If focus has escaped the provider, it is restored to the first eligible control. Escape nobody claims reaches useBackNavigation.
Tab
Left to the browser unless blockTabNavigation is set.
Behavior worth knowing when you debug a focus bug:
  • Arrow keys prefer candidates whose box overlaps the beam swept in the pressed direction, then the shortest weighted distance, so movement stays spatially predictable rather than following DOM order.
  • Every directional key event runs one movement step synchronously, so rapidly repeated arrow keys traverse consecutive candidates without waiting for focus animations to finish.
  • Elements that are hidden, inert, aria-hidden, fully transparent, resolving to pointer-events: none, zero-sized, or clipped outside their scroll viewport are not candidates. Only the element’s own resolved pointer-events is read, so a control that sets pointer-events: auto under an ancestor that turned them off is still reachable.
  • When no candidate exists, the provider asks the nearest scroll container to consume the movement. Only if that also fails does the focused control receive an invalid-direction signal, surfaced as onInvalidFocusDirection, which components turn into rubber-band feedback.
  • Where a real mouse is expected, a primary-button click that lands near a focusable moves focus to the nearest one, so a mouse behaves like reaching that element by arrow key. The provider turns this off on Meta Ray-Ban Display, where input is directional and a synthetic tap must not move focus.
useFocusNavigation() returns the same contract for programmatic control: isActive; moveFocus(direction) for 'up' | 'down' | 'left' | 'right', which returns whether the movement was handled — either focus moved or a scroll container consumed it; activateFocused(), which clicks the currently focused element; and clearFocus(), which — despite the name — pulls focus back to the first eligible control when it has escaped the provider or rests on the provider’s container element. It does nothing while a descendant control already owns focus.

Initial focus

With autoFocusFirst on, the coordinator focuses the top-left eligible control of the active page. It runs only when nothing on the active page already holds focus, so it never steals focus from the user, from PageTransition, or from the host application. Exclude a control from that choice with initialFocusEligible={false} — it stays reachable by directional navigation but will not be picked as the entry point. PageTransition also accepts an initialFocus prop, which defaults to 'top-left'; see Route Navigation.

Disabled controls stay focusable

disabled does not remove a control from the focus order. The control keeps its tab stop, reports aria-disabled, and drops to --uit-disabled-opacity, while activation is suppressed: onActivate, onClick, and onLongPress do not fire, and Enter/Space are consumed rather than passed through.
This is deliberate. A control that vanishes from directional navigation is a control nobody can discover, and there is nowhere else to explain why the action is unavailable. Give the reason with tooltipMode={TooltipMode.DISABLED_CLICK} and tooltipText, which surfaces the explanation when someone tries to activate the control.
import {Button, TooltipMode} from '@wearables-ui-toolkit/mrbd';

<Button
  title="Send"
  disabled={!isConnected}
  tooltipMode={TooltipMode.DISABLED_CLICK}
  tooltipText="Reconnect to send"
  onActivate={send}
/>;
Keep disabled controls focusable and explain the unavailable action. Hiding the control or setting focusable={false} removes the affordance and its explanation.

Announcing changing values

Progress and level indicators are not focusable, so a screen reader has nothing to land on and re-read when the value changes. Each of them renders role="progressbar" with aria-valuenow, aria-valuemin, and aria-valuemax, plus a visually hidden aria-live="polite" aria-atomic="true" region that announces the rounded percentage. The live region is opt-in per component through announceUpdatesForAccessibility.
ComponentannounceUpdatesForAccessibility defaultAccessible name
ProgressIndicator
true
The rounded percentage, with your aria-label appended after a comma when you pass one.
ProgressRing
false
The rounded percentage, with your aria-label appended after a comma when you pass one.
VolumeIndicator
false
The rounded percentage followed by Volume, with your aria-label replacing Volume.
ZoomIndicator
false
The rounded percentage, with your aria-label appended after a comma when you pass one.
Turn announcements on when the value changes as a result of something the user just did, and leave them off for ambient or continuously updating values, which would otherwise talk over everything else.
import {VolumeIndicator} from '@wearables-ui-toolkit/mrbd';

<VolumeIndicator value={volume} announceUpdatesForAccessibility />;
CircularProgressBar and IndeterminateLoader also render role="progressbar"; IndeterminateLoader names itself Loading unless you pass aria-label.

Dismissal and Back

  • Escape and system Back dismiss the topmost transient surface before they change routes. A tooltip, context menu, or vertical menu consumes the key first.
  • ContextMenu reports why through onDismiss, with DismissReason.BACK_BUTTON for the key press and DismissReason.NAVIGATION when directional movement leaves the menu. VerticalMenu reports the same split through onDismissRequest with VerticalMenuDismissReason.ESCAPE and VerticalMenuDismissReason.NAVIGATION.
  • After a surface closes, focus returns to the control that opened it. Verify this, because a control that unmounts while open cannot receive focus back.
  • Handle unclaimed Escape at the application boundary with useBackNavigation. Return false when you cannot navigate back so the browser keeps its default handling; any other return value claims the event.
import {useBackNavigation} from '@wearables-ui-toolkit/mrbd';

useBackNavigation(() => {
  if (!canGoBack) {
    return false;
  }
  goBack();
});
  • Back is a system action, so applications must not render an in-app back button.

Reduced motion

Honor prefers-reduced-motion: reduce in both directions of the stack.
import {
  AnimationDurations,
  reducedMotionDuration,
  usePrefersReducedMotion,
} from '@wearables-ui-toolkit/mrbd';

const prefersReducedMotion = usePrefersReducedMotion();
const durationMs = reducedMotionDuration(
  AnimationDurations.CONTAINER_STATE_CHANGE,
  prefersReducedMotion,
);
usePrefersReducedMotion subscribes to the media query and is safe to call during server rendering, where it reports false. reducedMotionDuration collapses a duration to zero so a value snaps to its target instead of tweening; useSpringAnimation applies the same rule for you and snaps to its target.
Part of the library reads the preference on your behalf: Container and StaticContainer state changes, material cross-fades and layout transitions, Button expansion and its action transition, ButtonRail focus-follow scrolling, Scrubber, and PageTransition. The rest does not, so gate it yourself. Pass !usePrefersReducedMotion() into a component’s motion switch wherever one exists — animated on Pager, SliderBar, Switch, ProgressIndicator, ProgressRing, CircularProgressBar, IsolatedControl, SwipeIndicator, VolumeIndicator, ZoomIndicator, and TextSwitcher, or animate on Vignette. Presentation fades that expose no switch, such as Tooltip and Toast, always animate, so never let the animation itself be the only carrier of the message. See Motion for the full rules.
For anything driven by CSS transitions, add the matching guard:
@media (prefers-reduced-motion: reduce) {
  .panel {
    transition: none;
  }
}
Reducing motion must never remove information. The end state still has to communicate focus, selection, and progress.

Testing

  1. Complete every task with arrow keys and the activation key only, with no pointer.
  2. Repeat each direction rapidly at the first and last item of every scroll region and confirm focus, scrolling, and animation stay in sync.
  3. Open and dismiss every transient surface with Escape and with system Back, and confirm focus returns to the trigger.
  4. Navigate forward and back through every route and confirm the previously focused item and scroll offset are restored before the page is visible.
  5. Focus every disabled control and confirm it explains itself instead of silently doing nothing.
  6. Read the accessibility tree: check names, roles, values, heading order, and that no decorative indicator appears as a widget.
  7. Re-run the walkthrough with reduced motion enabled and at a materially different viewport size.
  • Name actions by outcome so assistive technology announces what activation does.
  • Keep semantics on the one element that owns the action.
  • Label every scroll region, list, and menu so boundaries are announced.
  • Let components own roles and focus, and pass application meaning through public props.

Avoid

  • Keep decorative visuals out of the focus order rather than adding tabIndex.
  • Pair color with another cue because state must not rely on color alone.
  • Leave browser Tab traversal available by default so keyboard and assistive-technology users retain a working path. Set blockTabNavigation only for a surface that is genuinely directional-input-only.
  • Use component dismissal callbacks rather than layering additional key handlers on a component that already reports dismissal.