Skip to content

fix: expose input styling on RAC Checkbox and Radio - #10517

Open
gonzoblasco wants to merge 5 commits into
adobe:mainfrom
gonzoblasco:fix/checkbox-radio-sr-focus-indicator
Open

fix: expose input styling on RAC Checkbox and Radio#10517
gonzoblasco wants to merge 5 commits into
adobe:mainfrom
gonzoblasco:fix/checkbox-radio-sr-focus-indicator

Conversation

@gonzoblasco

@gonzoblasco gonzoblasco commented Aug 27, 2026

Copy link
Copy Markdown

Why

Screen readers (VoiceOver, NVDA) draw their focus indicator around the native
<input> element, not around the visible component. In Checkbox and Radio the
input is rendered inside VisuallyHidden, which collapses it to 1x1px
(width: 1px; height: 1px; position: absolute; clip: rect(0 0 0 0)). The result
is that the screen reader focus ring shows up as a tiny square in the corner of
the component, disconnected from the visual focus. That is confusing for users
with low vision who rely on both the visual focus cue and the assistive
technology outline.

What changed

Expose inputClassName/inputStyle and visuallyHiddenClassName/visuallyHiddenStyle
on Checkbox, CheckboxField, Radio, RadioField, CheckboxButton, and RadioButton so
users can size and position both the hidden input and its VisuallyHidden wrapper
to encompass the visible component, making the screen reader focus ring match the
visual one.

The props follow the existing inputRef pattern already on these components and
are optional, so there is no behavior change by default.

Usage

To make the screen reader focus ring match the component, the label must be a
positioned containing block (position: relative), the VisuallyHidden wrapper
must be stretched to the label, and the input must fill the wrapper:

<Checkbox
  style={{position: 'relative'}}
  visuallyHiddenStyle={{inset: 0, width: 'auto', height: 'auto'}}
  inputStyle={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}>
  Accept
</Checkbox>

The position: relative on the label is required because the input is
absolutely positioned; without a positioned ancestor it would resolve against
the viewport instead of the component. This is documented on the new props.

Testing

  • Added real-browser layout tests (not jsdom) that measure the hidden input's
    bounding box against the component, verifying the input covers the component
    rather than the viewport. This addresses the review feedback that jsdom cannot
    validate layout.
  • All 128 tests in the Checkbox and RadioGroup suites pass, check-types and
    lint are clean.

Fixes #9687

Findings

While implementing this fix I found a couple of related cases that are out of scope for this PR but worth flagging:

  • Switch uses the same pattern as Checkbox and Radio: its native input is rendered inside VisuallyHidden and collapsed to 1x1px, so it likely has the same screen reader focus indicator issue. It does not have inputClassName/inputStyle either. Since [Bug]: Screen reader focus indicator is misaligned for Checkbox and Radio components [react-aria-components] #9687 only mentions Checkbox and Radio, I did not expand the scope here, but Switch is a good candidate for a follow-up.
  • Slider, Table, and HiddenDateInput also render a native input inside VisuallyHidden. These are different cases (range input, selection, and date input respectively), but if the same fix is wanted across the library, the inputClassName/inputStyle pattern could be extended consistently.

Happy to follow up on Switch if the maintainers want it. Otherwise these are documented for future work.

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR. I don't think this is going to work. VisuallyHidden wraps the input in a position absolute. So anything inside it will be positioned relative to it. In addition, it has height and width of 1, so anything inside it using height/width 100% would still only be 1px.
I'd start by proving that it works in the browser using storybook, not unit tests, jsdom is a pretty bad place to test that this works.

@gonzoblasco

Copy link
Copy Markdown
Author

@snowystinger you are right, and I verified it in a real browser. Thanks for the push to test this properly instead of relying on jsdom.

I reproduced the exact VisuallyHidden styles (position absolute, 1x1px, clip rect(0 0 0 0), clip-path inset(50%)) in a Chromium page and measured the real bounding boxes of the hidden input vs the visible component. The results:

  • inputStyle={{inset: 0}} (as proposed): the input is position: static, so inset is ignored and it keeps its natural size (~153x21), which does not match the component.
  • inputStyle={{inset: 0, position: 'absolute'}}: the input is now positioned relative to the 1x1px wrapper, so inset: 0 collapses it to roughly 1x1px. Same with width/height: 100% - it resolves against the 1x1px wrapper, not the component.
  • position: fixed + inset: 0: covers the whole viewport, not the component, and would break the click target.

So exposing inputStyle/inputClassName cannot make the screen reader focus ring match the visual one. The VisuallyHidden wrapper imposes the 1x1px absolute box plus the clip, and the input cannot escape that with reasonable inline styles. The jsdom tests only assert the attribute is present, not that the layout works - exactly the gap you flagged.

I'll rework this PR with a different approach. The real fix likely needs to change how the hidden input is laid out for these components (or how the screen reader focus ring is drawn), rather than exposing styles on the input. I'll dig into #9687 and come back with something that actually works in the browser.

Screen readers (VoiceOver, NVDA) draw their focus indicator around the
native input element, not the visible component. In Checkbox and Radio
the input is rendered inside VisuallyHidden, which collapses it to 1x1px,
so the screen reader focus ring shows up as a tiny square disconnected
from the visual focus.

Expose inputClassName/inputStyle and visuallyHiddenClassName/visuallyHiddenStyle
on Checkbox, CheckboxField, Radio, RadioField, CheckboxButton, and RadioButton
so users can size and position both the hidden input and its VisuallyHidden
wrapper to encompass the visible component. The props follow the existing
inputRef pattern and are optional, so there is no behavior change by default.

To make the screen reader focus ring match the component, the label must be
a positioned containing block (position: relative), the VisuallyHidden wrapper
must be stretched to the label (e.g. {inset: 0, width: 'auto', height: 'auto'}),
and the input must fill the wrapper ({position: 'absolute', inset: 0, width: '100%', height: '100%'}).
This is documented on the new props.

Adds real-browser layout tests (not jsdom) that measure the hidden input's
bounding box against the component, verifying the input covers the component
rather than the viewport. This addresses the review feedback that jsdom
cannot validate layout.

Fixes adobe#9687
@gonzoblasco
gonzoblasco force-pushed the fix/checkbox-radio-sr-focus-indicator branch from 1de6149 to 393b02c Compare August 27, 2026 12:21
Comment thread packages/react-aria-components/src/Checkbox.tsx Outdated
@gonzoblasco

Copy link
Copy Markdown
Author

Validated the story I added (CheckboxScreenReaderFocusRing and RadioGroupScreenReaderFocusRing) on Chrome desktop with VoiceOver - the focus indicator tracks the checkbox and its label, which is exactly the reported issue.

Validating visually also surfaced a deeper preexisting bug in RAC Checkbox and RadioGroup that the current fix does not solve, and I want to flag it before we go further with the current approach.

Observation: in Chrome desktop (with system theme), the native browser chrome for the checkbox/radio input is rendered on top of the custom SVG mark when the component is checked. This is visible on:

  • CheckboxExample (no fix, original story) - the native check square shows inside the SVG box.
  • RadioGroupExample and RadioGroupControlledExample (no fix) - the native radio dot shows inside the SVG ring.
  • CheckboxScreenReaderFocusRing and RadioGroupScreenReaderFocusRing (with the fix applied) - the native chrome becomes more prominent because the stretched input is now positioned over the custom SVG.

So the current fix exposes four style props that solve the bounding box issue (the input now covers the visible component) but does not solve the native chrome overlay. In fact, by stretching the input over the visible component, the native chrome overlay is now bigger and more obvious.

Root cause: the hidden input has appearance: auto (the default), so the browser renders the native widget on top of any CSS clip the VisuallyHidden wrapper applies. The VisuallyHidden wrapper clips the bounding box, but the native widget render does not respect CSS clip in modern Chrome/Firefox/Safari. The fix would need to set appearance: none on the input for the chrome to actually be hidden, but that is not something a consumer should have to know to add manually.

This means the smaller API you suggested is not just a nice-to-have - it is necessary to solve the actual bug. A single boolean prop that internally does the positioning correctly AND applies appearance: none to the input would solve both the SR focus ring and the native chrome overlay in one place, and it cannot be used to accidentally regress the appearance of the component.

Two questions before I rework the PR:

  1. Does the boolean prop direction sound right, or would you prefer something different (a separate inputClassName only, or keeping the four props and documenting the appearance: none requirement)?
  2. Should the boolean default to true (fix the chrome overlay for everyone, since it is currently a bug) or false (opt-in, to avoid any visual change for existing users)?

Want me to put together the smaller prop proposal once we agree on the direction?

@snowystinger

Copy link
Copy Markdown
Member

I'm not sure what the prop should look like, a boolean probably isn't enough. If you refer back to the conversation on the Issue, one of the worries was that there were multiple ways that someone could want to style everything. However, I think it might be better to name those scenarios and add them as we come across them.

For instance, you're currently demoing the "full height and width in a relative element" (open to naming ideas). Since you're using AI, you might ask it to think of other scenarios.

The other thing we could do is, if position relative is the resolved style on the checkbox (check in a useLayoutEffect), then we could apply the full height/width automatically. I'm not sure if that'll break anything. It'd be susceptible to being changed outside of React's lifecycle as well. But maybe there are other ideas to explore around this.

@gonzoblasco

gonzoblasco commented Aug 28, 2026

Copy link
Copy Markdown
Author

Good points. A boolean definitely isn't enough. I think the explicit style props we have now are the right escape hatch for arbitrary scenarios, but a named prop for the common case would be much cleaner.

The scenario I'm demoing is essentially "stretch the hidden input to the visual label". We could expose that as hiddenInput="stretch-to-label" (or inputLayout="stretch-to-label", naming TBD). It would apply the absolute-inset styles to the VisuallyHidden wrapper and the native input internally. The consumer would still need to make the label a positioned containing block (position: relative), and we'd document that requirement. We could add a dev-only warning if the prop is used but the label isn't positioned.

Other scenarios I can think of:

  • Stretch to button for CheckboxButton/RadioButton - the containing block is already the button itself, so this could apply without extra consumer setup.
  • Custom hit area - e.g. a checkbox inside a clickable card where the hidden input needs to cover the whole card. That's best handled by the explicit style props, since the coordinates depend on the consumer's layout.
  • Match the visible focus ring - we can't control the SR's ring color/thickness, but we can at least align its bounding box with the visible ring via the explicit style props.

On auto-detecting position: relative with useLayoutEffect: I'd lean against it. The resolved style may not be available at first paint if it comes from async CSS-in-JS injection, the containing block might be an ancestor rather than the checkbox itself, and silently mutating the hidden input's style outside React feels fragile with SSR. An explicit prop seems more predictable.

If you want, I can update the PR with hiddenInput="stretch-to-label" and keep the explicit style props as the advanced escape hatch. What do you think of the naming and direction?

@snowystinger

Copy link
Copy Markdown
Member

sure, lets see what that looks like, but get rid of the "advanced escape hatch".

Another thought I had was, we could provide a component with a context that the VisuallyHidden component could consume to change styles without needing to expose all of the styling. It could be used around any target and could specify a target element that the VisuallyHidden component should be positioned via.

<VisuallyHiddenPositioner target={elem ?? defaults to itself} className style>
  <Checkbox>Click me!</Checkbox> // based off RAC example in docs
</VisuallyHiddenPositioner> 

Where VisuallyHiddenPositioner sets position relative by default, and passes a context which tells the VisuallyHidden element to use top/end/bottom/start = 0, or whatever other position it determines from target.

It does have the unfortunate problem that if a position relative element was positioned between this VisuallyHiddenPositioner and the VisuallyHidden element, then measurements could be wrong. So we'd have to document that.

So we could also try using https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor with a fallback to what I outlined above since it's only "newly available" not "baseline" yet

…ch-to-label" on RAC Checkbox and Radio

Per maintainer direction on adobe#9687, remove the advanced escape hatch of four
style props (inputClassName/inputStyle/visuallyHiddenClassName/visuallyHiddenStyle)
on Checkbox/CheckboxField/CheckboxButton and Radio/RadioField/RadioButton.

Replace them with a single hiddenInput="stretch-to-label" prop that
internalizes the styles: it stretches the hidden native input and its
VisuallyHidden wrapper to cover the visible label, so the screen reader focus
ring (VoiceOver/NVDA draw the ring around the native input) matches the visual
focus. Requires the label (or an ancestor) to be a positioned containing block
(position: relative). No change in behavior by default.

Update the unit tests, the sr-focus browser test, and the stories to use the
new prop.
@gonzoblasco

gonzoblasco commented Sep 2, 2026

Copy link
Copy Markdown
Author

Hi @snowystinger. Reworked per your direction: the four style props are gone, replaced by a single hiddenInput="stretch-to-label" prop on Checkbox, CheckboxField, CheckboxButton, Radio, RadioField, and RadioButton.

What it does internally:

  • The VisuallyHidden wrapper gets {inset: 0, width: 'auto', height: 'auto'} (merged over the base 1x1px styles, so it stretches to the positioned containing block).
  • The native input gets {position: 'absolute', inset: 0, width: '100%', height: '100%'} so it covers the visible label.
  • The consumer still needs position: relative on the label (or a positioned ancestor); that's documented on the prop.

No escape hatch, no behavior change by default. The stories and the sr-focus browser test now use the new prop, and the unit tests assert the input receives the stretch styles.

One thing I want to flag for the follow-up: the native chrome overlay (appearance: auto rendering on top of the custom SVG mark when checked) is still there - stretching the input makes it more visible, not less. That needs appearance: none on the input, which I left out of this PR since it's a separate decision. Happy to tackle it next, or to explore the VisuallyHiddenPositioner idea you sketched.

@gonzoblasco

Copy link
Copy Markdown
Author

Verification update: the sr-focus browser test now runs green in real browsers, not just jsdom. Ran the layout test (hidden input bounding box covers the visible component) on:

Browser Result
Chromium (headless shell 143) 2/2 passed
Firefox (144.0.2) 2/2 passed

The test measures the hidden input's bounding box against the label's and asserts the input covers the component (not the viewport), which is the layout property that makes the screen reader focus ring align with the visual one. Unit tests remain 128/128 green.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Screen reader focus indicator is misaligned for Checkbox and Radio components [react-aria-components]

2 participants