Skip to content

@k0ndee/focus indicators - #5107

Draft
k0ndee wants to merge 5 commits into
callstack:mainfrom
k0ndee:@k0ndee/focus-indicators
Draft

@k0ndee/focus indicators#5107
k0ndee wants to merge 5 commits into
callstack:mainfrom
k0ndee:@k0ndee/focus-indicators

Conversation

@k0ndee

@k0ndee k0ndee commented Sep 7, 2026

Copy link
Copy Markdown

Fixes #5083

Supersedes #5084 (@lukemorawski's original implementation - includes his three commits as-is). This rebuilds the web half of the ring per feedback that it should be plain CSS :focus-visible, not JS-tracked focus state, and fixes a couple of things a fresh pass turned up.

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 this diff drops to just the focus work.

Motivation

No component in the library had a visible keyboard focus indicator, and the three that improvised one (FAB, Checkbox, Switch) each did it differently. MD3 requires a visible focus indicator on every interactive component; this adds one shared implementation, used everywhere TouchableRipple is, plus Card and Chip's close button.

What

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.

src/utils/useFocusRing.ts holds the one shared hook. FAB's local hook is deleted; FAB, Checkbox, Switch, Card, and Chip all use the shared one. TouchableRipple gets a focusRing prop, 'outward' | 'inward' | 'none'.

Changed from #5084: the web ring is now pure CSS, not JS-tracked state. A data-focus-ring[-within] attribute plus one injected stylesheet key off the browser's own :focus-visible / :has(:focus-visible); only the (theme dependent) ring color crosses into CSS, as a custom property. Nothing tracks focus in JS on web anymore - the browser drives it. Native keeps JS-tracked state (onFocus/onBlur + a live outline* style), since it has no CSS.

Placement

Outward by default, which is what outerOffset in src/theme/tokens/sys/state.ts is for. Inward on Chip's body and close button, FAB's menu items, and Switch's track.

Not a style preference. An outward ring sits outside the box and gets trimmed by any clipping ancestor sized to its content - a row of chips in a horizontal ScrollView loses its top and bottom and renders as two arcs. Inward fits. Making inward the default instead doesn't work either: MD3 tonal palettes are luminance-matched by tone, so a secondary ring on top of another role at the same tone is close to 1:1 contrast and disappears. Outward puts it on the page background instead, where contrast is predictable.

Notes for review

  • Keyboard only. Web now trusts the browser's real :focus-visible semantics directly - no JS heuristic needed there anymore. Native still uses isKeyboardFocusEvent, which is hardcoded true there: RN doesn't distinguish touch- from keyboard-triggered onFocus on native, relying on the fact that a plain touch tap doesn't normally call onFocus on a non-text control in the first place.
  • TouchableRipple's ring is now keyed off the caller's real disabled prop, not the internal disabled || !hasPassedTouchHandler conflation used elsewhere in that file (a separate, tracked bug in the primitive) - so the ring isn't coupled to it.
  • Chip's close button is a plain Pressable, not a TouchableRipple, so it calls useFocusRing directly instead of going through the focusRing prop like the chip body does - now called out in a comment so the two patterns don't read as an oversight.
  • FAB's Shell/Menu pass undefined as the disabled argument to useFocusRing, since neither has a disabled prop today - also now commented, as a flag for whoever adds one later.
  • The native focus-ring test derives its thickness/offset assertions from tokens.md.sys.state.focusIndicator instead of hardcoding them, matching the web stylesheet test's own stated rule.
  • iOS: Fabric's view does call -becomeFirstResponder/-resignFirstResponder for hardware-keyboard/Full Keyboard Access navigation, but only emits the onFocus/onBlur JS event when the native enableImperativeFocus feature flag is on, and it defaults off (old architecture has no equivalent path at all). Pre-existing - the old FAB/Checkbox/Switch rings were equally inert on iOS before this PR.
  • ~1k lines of snapshot changed. The old rings were always mounted at opacity: 0; the ring markup now only exists while actually focused.

Test plan

yarn typecheck, yarn lint, yarn test clean.

  • Manual pass per component on web and Android, hardware keyboard: ring shows on Tab, no ring on mouse/touch, not clipped by any clipping ancestor (chips in a horizontal scroll row, list items, segmented buttons).
  • iOS: confirm no ring appears with a hardware/Bluetooth keyboard attached, consistent with the enableImperativeFocus note above (rendering only, not a regression check).
  • Re-review the ~1k lines of changed snapshots for anything beyond the expected ring-markup removal.

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

2 participants