Skip to content

feat: add MD3 keyboard focus indicators - #5084

Open
lukemorawski wants to merge 3 commits into
callstack:mainfrom
lukemorawski:feat/focus-indicators
Open

feat: add MD3 keyboard focus indicators#5084
lukemorawski wants to merge 3 commits into
callstack:mainfrom
lukemorawski:feat/focus-indicators

Conversation

@lukemorawski

@lukemorawski lukemorawski commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #5083

Based on #5080 and contains its two commits, since a fork cannot open a stacked PR.
Worth reviewing after that one merges, at which point I will rebase and this diff
drops to just the focus work.

What

MD3 keyboard focus indicators, from one shared implementation, on every component
built on TouchableRipple plus Card and Chip's close button.

The ring is the platform's own outline, not a bordered child view. It costs no
layout, takes its corner radius from the view it sits on, and is not clipped by that
view's own overflow: 'hidden', so borderless is not a problem. outline* landed
in RN 0.77 and Paper's floor is already 0.81 via the reanimated peer dep.

New src/utils/useFocusRing.ts holds the hook, the style helper and the one
webNoOutline. FAB's local hook is deleted and FAB, Checkbox and Switch are
moved onto the shared one, so the three hand-rolled rings become one.

TouchableRipple gets a focusRing prop, 'outward' | 'inward' | 'none'.

rings-web

Placement

Outward by default, which is what outerOffset in
src/theme/tokens/sys/state.ts is for. Inward on Chip, List.Item,
RadioButton.Item and SegmentedButtons.

Not a style preference. An outward ring is 5dp outside the box, and it gets trimmed
by any clipping ancestor sized to its content. A row of chips in a horizontal
ScrollView is the case that shows up in this repo's own example app, same on web
and Android: the ring loses its top and bottom and renders as two arcs. Inward fits.
List.Item and SegmentedButtons are inward for the neighbour version of the same
problem, full bleed rows and flush segments. material-web does the same split and
uses inward for its list item and tab.

chip-inward-why

Going the other way and making inward the default does not work either. MD3 tonal
palettes are luminance matched by tone, so a secondary ring drawn on top of another
role at the same tone is about 1:1 and disappears. On a contained Button fill that
is 1.00:1. Outward puts it on the page background instead, 6.13:1 light and 10.91:1
dark. There is a test for this :)

Notes for review

  • Keyboard only, via the existing isKeyboardFocusEvent. Pressable's own focused
    state fires for clicks, Pressable: focus-visible support necolas/react-native-web#1849.
  • webNoOutline stays on FAB and Switch only, since both ring an inner element.
    Nowhere else sets outline: none, so a JS failure still leaves the browser ring.
  • ~1k lines of snapshot went bye bye. The old rings were always mounted at opacity: 0;
    this one only exists while focused. Index based tree queries against FAB and
    Switch will shift.
  • iOS renders the ring but never focuses it, enableImperativeFocus is off by
    default. Pre existing, the old FAB/Checkbox/Switch rings were inert there too.
  • Docs not regenerated. docs/scripts/generate-current-component-docs.ts deletes its
    output dir before parsing and then crashes on satisfies. Pre existing, needs it's own PR.

Testing

yarn typecheck, yarn lint, yarn test clean

Two tricky things that cost me time:

  • A bare TouchableRipple import resolves to .native, so the web file goes
    untested unless you import it by extension
  • RNTL will not dispatch to a disabled element, so negative focus assertions pass
    for free

Manual pass per component on web and Android, hardware keyboard: ring on tab, no ring
on mouse click, not clipped. iOS rendering only, for the reason above.

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.

No keyboard focus indicator on most components, and the three that have one each did it differently

1 participant