InteractableBase, which resolves the following on its root element.| Concern | Behavior |
|---|---|
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. |
import {Button} from '@wearables-ui-toolkit/mrbd';
<Button icon={muteIcon} ariaLabel="Mute microphone" onActivate={mute} />;
Button with a title is named by its title. Add ariaLabel only when the visible text is missing, truncated, or ambiguous out of context.tooltipContent instead of tooltipText, pass tooltipContentDescription too, or the popup has no accessible name.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.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.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.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'.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.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.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>;
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
| Key | Behavior |
|---|---|
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. |
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.onInvalidFocusDirection, which components turn into rubber-band feedback.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.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 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.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}
/>;
focusable={false} removes the affordance and its explanation.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.| Component | announceUpdatesForAccessibility default | Accessible 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. |
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.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.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();
});
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.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.@media (prefers-reduced-motion: reduce) {
.panel {
transition: none;
}
}
tabIndex.blockTabNavigation only for a surface that is genuinely directional-input-only.