Skip to content

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

Description

@lukemorawski

Part of #4990

Clean up Banner accessibility so hidden content is actually hidden/inert, live regions behave consistently across platforms, and animation callbacks only fire when a real show/hide transition happens.

Banner doesn't exist in MD3 anymore, but it's still a supported Paper extension, so this is mostly about making its behaviour internally sane and accessible rather than matching the Material spec exactly.

Problems

  • Hidden Banner content is still mounted and reachable. It's only translated off-screen and clipped — screen readers can still see it and action buttons stay tabbable. Verified in the browser a11y tree: with visible={false}, both actions still have tabIndex: 0 and can receive focus.
  • role="alert" + aria-live="polite" doesn't really make sense. alert normally implies assertive + atomic, while Chrome currently ends up with alert atomic live="polite".
  • The live region currently does nothing on Android. RN maps aria-live to accessibilityLiveRegion on View, but not on Text, so the prop is effectively ignored there. It only works on web today.
  • iOS has no live-region equivalent, so the message isn't announced there either.
  • actions is unlimited and actions always sit below the message. Material allows max two.
  • If a focused action disappears after the actions array changes, focus gets dumped at the top of the page.
  • onShowAnimationFinished / onHideAnimationFinished fire on mount and again when theme.animation.scale changes, even though no visibility transition happened. Existing tests already call this out as probably a bug.

Focus Areas

  • Make hidden content properly inert: no pointer interaction, no a11y exposure, no focus.
  • Put the live region on a View so Android actually supports it, and keep it scoped to the message so action labels don't cause the whole banner to re-announce.
  • Make role and aria-live agree: polite by default, assertive when explicitly requested.
  • Announce manually on iOS without double-announcing on Android/web.
  • Limit actions to two and let them reflow depending on available width.
  • Restore focus if the currently focused action disappears.
  • Run show/hide animations and callbacks only for actual visible transitions.

Proposed API

One new prop, everything else is behavioural:

<Banner visible urgent actions={[...]}>
  Your payment failed.
</Banner>
  • urgent?: boolean
    • false (default): role="status" + aria-live="polite"
    • true: role="alert" + aria-live="assertive"; on iOS it interrupts instead of queueing

Breaking changes

  • Banner children are unmounted once fully hidden, so local child state is lost after hiding.
  • More than two actions are ignored, with a dev warning.

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions