Skip to content

fix(banner): make hidden content inert and fix live region semantics - #5056

Open
lukemorawski wants to merge 1 commit into
callstack:mainfrom
lukemorawski:feat/banner-a11y-inert-when-hidden
Open

fix(banner): make hidden content inert and fix live region semantics#5056
lukemorawski wants to merge 1 commit into
callstack:mainfrom
lukemorawski:feat/banner-a11y-inert-when-hidden

Conversation

@lukemorawski

@lukemorawski lukemorawski commented Aug 18, 2026

Copy link
Copy Markdown

Motivation

Banner was only visually hidden but content stayed mounted, screen readers could still reach it, and action buttons remained tabbable. The live region also had conflicting semantics and simply didn't work on Android.

  • Hidden content was still reachable. The banner was just translated off-screen and clipped - no aria-hidden, pointerEvents guard or inert. With visible={false}, both actions still had tabIndex: 0 and accepted focus in the browser a11y tree.
  • role="alert" + aria-live="polite" didn't agree. alert implies assertive + atomic, while Chrome ended up with alert atomic live="polite".
  • The live region was dead on Android. RN maps aria-liveaccessibilityLiveRegion on View, not Text, so the prop was ignored by the native text node. In practice it only worked on web.
  • iOS has no live regions, so nothing announced the message there either.
  • actions was unbounded, and animation callbacks fired on mount and on theme.animation.scale changes even when visibility didn't change. Two existing tests already called this out as probably a bug.

Related issue

Fixes #5055
Part of #4990

Changes

Inertness. Content becomes aria-hidden + pointerEvents="none" + inert on web as soon as hiding starts, then unmounts after the exit animation - basically the same lifecycle Snackbar already uses. There's still one inert measuring pass when mounting hidden so the spacer gets the right height. Layout stays the same.

Live region. Moved from Text to a View, which makes it actually work on Android. It's also scoped to the message only, so action labels don't re-announce the whole banner. role and aria-live now match.

New urgent prop.

<Banner visible urgent actions={[...]}>
  Your payment failed.
</Banner>
  • false by default: role="status" + aria-live="polite"
  • true: role="alert" + aria-live="assertive"

On iOS the message is announced explicitly with announceForAccessibilityWithOptions({ queue: !urgent }): queued for normal banners, interrupting for urgent ones. This is iOS-only to avoid double announcements elsewhere. Interpolated children like <Banner>Hello {name}</Banner> are flattened before announcing.

Actions. Limited to 2, with a development warning for extras. They can now sit inline with the message when there's enough room instead of always dropping below it.

Focus. If a focused action disappears, focus moves to the nearest remaining action, or the message region if there are none. During hiding the content is inert, so focus is simply released. Restoring it to whatever opened the banner needs consumer-owned API/state and is out of scope here.

Callbacks. onShowAnimationFinished / onHideAnimationFinished now only run after actual visible transitions - not on mount, animation-scale changes, or interrupted animations.

Breaking changes

  • Banner children unmount once hidden, so local child state is lost.
  • More than 2 actions are ignored, with a dev warning.
  • Actions may render inline instead of always below the message.

Test plan

  • yarn typecheck
  • yarn lint
  • yarn test - 55 suites, 760 tests. Banner coverage went from 13 to 41 tests; the two "probably a bug" tests were inverted rather than removed.
  • Chrome browser accessibility tree (expo start --web)
  • Mobile Safari (iOS 18.3 simulator) + Chrome (Android 16 emulator)
  • Android native + TalkBack
  • iOS native + VoiceOver

Hidden state, browser a11y tree. Before, both actions were still focusable:

{"totalTabbablesOnPage":60,
 "bannerActionsStillTabbable":[
  {"label":"Set custom theme","tabIndex":0,"receivedFocus":true,"insideInert":false,"insideAriaHidden":false},
  {"label":"Fix it","tabIndex":0,"receivedFocus":true,"insideInert":false,"insideAriaHidden":false}]}

Afterwards the message and actions disappear from the tree completely. The page tab count drops by exactly those two buttons (from 31 to 29), then returns when the banner is shown again.

Visible state. alert atomic live="polite" becomes status atomic live="polite". The live region contains only the message; buttons are siblings.

Android / TalkBack. The native a11y tree contains banner-content, message and actions while visible, and none of them while hidden.

iOS / VoiceOver. Full show > hide > show cycle gives message + actions > [] > message + actions again. The urgent announcement was also checked.

@MikitasK MikitasK left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks pretty solid 👍
just a few points to address before merge:

Comment thread src/components/Banner.tsx
Comment on lines +212 to +217
}).start((result) => {
if (result.finished) {
setExited(true);
}
hideCallback(result);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should invoke hideCallback only when result.finished === true ?

Suggested change
}).start((result) => {
if (result.finished) {
setExited(true);
}
hideCallback(result);
});
}).start((result) => {
if (result.finished) {
setExited(true);
hideCallback(result);
}
});

Comment thread src/components/Banner.tsx
Comment on lines +365 to +371
<View
ref={messageRef}
testID={`${testID ?? 'banner'}-message`}
style={styles.message}
role={urgent ? 'alert' : 'status'}
aria-live={visible ? liveRegion : 'off'}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we make message focusable on web?
when last action is removed, we call .focus() on this View, but role="status" doesn't make rendered <div> focusable

maybe we can add tabIndex={-1} on web:

Suggested change
<View
ref={messageRef}
testID={`${testID ?? 'banner'}-message`}
style={styles.message}
role={urgent ? 'alert' : 'status'}
aria-live={visible ? liveRegion : 'off'}
>
<View
ref={messageRef}
testID={`${testID ?? 'banner'}-message`}
style={styles.message}
role={urgent ? 'alert' : 'status'}
aria-live={visible ? liveRegion : 'off'}
{...(Platform.OS === 'web' ? { tabIndex: -1 } : { accessible: true })}
>

also it'd be great to verify this behavior in Banner.text.tsx

Comment thread src/components/Banner.tsx
Comment on lines +233 to +235
const message = React.Children.toArray(children)
.filter((child) => typeof child === 'string' || typeof child === 'number')
.join('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about nested text elements? current code only collects direct string & number children, so messages like that would announce only "Payment " on iOS:

<Banner visible>
  Payment <Text>failed</Text>
</Banner>

maybe we can recursively collect text from nested children? or provide explicit announcement label?

Comment thread src/components/Banner.tsx
textColor={colors.primary}
theme={theme}
{...others}
touchableRef={actionRefs.current[i]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we preserve touchableRef passed through an action? internal touchableRef currently overrides the consumer’s ref. maybe we can merge both refs instead:

Suggested change
touchableRef={actionRefs.current[i]}
touchableRef={mergeRefs(actionRefs.current[i], touchableRef)}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(banner): make hidden content inert and fix live region semantics

2 participants