Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
393b02c
fix: expose input and VisuallyHidden styling on RAC Checkbox and Radio
gonzoblasco Aug 27, 2026
3d19d3e
Merge branch 'main' into fix/checkbox-radio-sr-focus-indicator
gonzoblasco Aug 28, 2026
182d451
feat: add stories for screen reader focus ring on Checkbox and Radio
gonzoblasco Aug 28, 2026
f23be64
Merge branch 'main' into fix/checkbox-radio-sr-focus-indicator
gonzoblasco Sep 2, 2026
c07895e
fix: replace input/visuallyHidden style props with hiddenInput="stret…
gonzoblasco Sep 2, 2026
47ae4ee
Merge branch 'main' into fix/checkbox-radio-sr-focus-indicator
gonzoblasco Sep 12, 2026
9a427b0
Merge branch 'main' into fix/checkbox-radio-sr-focus-indicator
gonzoblasco Sep 18, 2026
5818570
Merge branch 'main' into fix/checkbox-radio-sr-focus-indicator
gonzoblasco Sep 21, 2026
79e3cc9
fix: implement anchor positioning for hidden inputs in Checkbox and R…
gonzoblasco Sep 21, 2026
0cf378d
fix: replace useId with random ID to fix CI tests crash
gonzoblasco Sep 21, 2026
508a29a
style: sort imports alphabetically to fix oxlint errors
gonzoblasco Sep 21, 2026
6d922cb
style: fix import sorting for oxlint
gonzoblasco Sep 21, 2026
be64a45
style: force update for import sorting
gonzoblasco Sep 21, 2026
6e55c11
cleanup: remove spike and format
gonzoblasco Sep 21, 2026
70e2b8c
fix: remove residual useHiddenInputAnchor and apply TS casting for CS…
gonzoblasco Sep 21, 2026
684a880
style: fix formatting in browser tests
gonzoblasco Sep 21, 2026
b46d575
fix: respect custom anchorName provided via style
gonzoblasco Sep 21, 2026
9bad6de
fix: casting props.style to any to avoid TS2339 on anchorName
gonzoblasco Sep 21, 2026
5482c58
test: fix RadioButton test to target label instead of input for posit…
gonzoblasco Sep 21, 2026
f09ec98
style: final formatting fix for CI
gonzoblasco Sep 21, 2026
887be2d
test: fix Checkbox test to target label instead of input for position…
gonzoblasco Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/react-aria-components/src/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,8 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct
let {isFocused, isFocusVisible, focusProps} = useFocusRing();
let isInteractionDisabled = isDisabled || isReadOnly;

let uniqueId = `checkbox-${Math.random().toString(36).substr(2, 9)}`;

let {hoverProps, isHovered} = useHover({
...props,
isDisabled: isInteractionDisabled
Expand Down Expand Up @@ -536,6 +538,11 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct
<dom.label
{...mergeProps(DOMProps, labelProps, hoverProps, renderProps)}
ref={ref}
style={{
...props.style,
['anchorName' as any]:
(props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}`
}}
slot={props.slot || undefined}
data-selected={isSelected || undefined}
data-indeterminate={isIndeterminate || undefined}
Expand All @@ -548,7 +555,20 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct
data-invalid={isInvalid || undefined}
data-required={isRequired || undefined}>
<VisuallyHidden elementType="span">
<input {...mergeProps(inputProps, focusProps)} ref={inputRef} />
<input
{...mergeProps(inputProps, focusProps)}
ref={inputRef}
style={{
position: 'fixed',
margin: 0,
['positionAnchor' as any]:
(props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}`,
top: 'anchor(top)',
left: 'anchor(left)',
width: 'anchor-size(width)',
height: 'anchor-size(height)'
}}
/>
</VisuallyHidden>
{renderProps.children}
</dom.label>
Expand Down
27 changes: 25 additions & 2 deletions packages/react-aria-components/src/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,11 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio

return (
<InternalRadioContext.Provider
value={{...aria, inputRef, defaultClassName: 'react-aria-Radio'}}>
value={{
...aria,
inputRef,
defaultClassName: 'react-aria-Radio'
}}>
<RadioButton {...props} ref={ref} />
</InternalRadioContext.Provider>
);
Expand Down Expand Up @@ -469,6 +473,8 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function
let {isFocused, isFocusVisible, focusProps} = useFocusRing();
let interactionDisabled = isDisabled || state.isReadOnly;

let uniqueId = `radio-${Math.random().toString(36).substr(2, 9)}`;

let {hoverProps, isHovered} = useHover({
...props,
isDisabled: interactionDisabled
Expand Down Expand Up @@ -498,6 +504,10 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function
<dom.label
{...mergeProps(DOMProps, labelProps, hoverProps, renderProps)}
ref={ref}
style={{
...props.style,
['anchorName' as any]: (props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}`
}}
data-selected={isSelected || undefined}
data-pressed={isPressed || undefined}
data-hovered={isHovered || undefined}
Expand All @@ -508,7 +518,20 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function
data-invalid={state.isInvalid || undefined}
data-required={state.isRequired || undefined}>
<VisuallyHidden elementType="span">
<input {...mergeProps(inputProps, focusProps)} ref={inputRef} />
<input
{...mergeProps(inputProps, focusProps)}
ref={inputRef}
style={{
position: 'fixed',
margin: 0,
['positionAnchor' as any]:
(props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}`,
top: 'anchor(top)',
left: 'anchor(left)',
width: 'anchor-size(width)',
height: 'anchor-size(height)'
}}
/>
</VisuallyHidden>
{renderProps.children}
</dom.label>
Expand Down
70 changes: 70 additions & 0 deletions packages/react-aria-components/src/hiddenInputAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {RefObject} from '@react-types/shared';
import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect';

let anchorSupport: boolean | null = null;
function getAnchorSupport(): boolean {
if (anchorSupport === null) {
anchorSupport =
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('anchor-name: --test');
}
return anchorSupport ?? false;
}

/**
* Positions the hidden native input of a component (e.g. Checkbox, Radio) over
* the component's outer element using CSS anchor positioning, so the screen
* reader focus indicator (VoiceOver and NVDA draw the ring around the native
* input) matches the visible component instead of collapsing to the 1x1px
* VisuallyHidden box.
*
* The input is taken out of flow with `position: fixed` and anchored to the
* outer element with `position-anchor` + `anchor()`/`anchor-size()`, which
* escapes the VisuallyHidden wrapper's 1x1px absolute box. If the outer
* element (or a consumer-provided class) declares an `anchor-name`, that name
* is used; otherwise a unique default name is applied inline. Browsers without
* CSS anchor positioning support keep today's behavior.
*
* CSS can change without notifying React, so a later `anchor-name` change is
* only picked up on re-render.
*/
export function useHiddenInputAnchor(
anchorRef: RefObject<HTMLElement | null>,
inputRef: RefObject<HTMLInputElement | null>,
defaultAnchorName: string
): void {
useLayoutEffect(() => {
let outer = anchorRef.current;
let input = inputRef.current;
if (!outer || !input || !getAnchorSupport()) {
return;
}

let name = getComputedStyle(outer).getPropertyValue('anchor-name').trim();
if (!name || name === 'none') {
name = defaultAnchorName;
outer.style.setProperty('anchor-name', name);
}

input.style.setProperty('position', 'fixed');

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.

most of this can be applied with css instead during render instead of in a layouteffect and most of it can be applied unconditionally since if it's not supported, it won't do anything

input.style.setProperty('margin', '0');
input.style.setProperty('position-anchor', name);
input.style.setProperty('top', 'anchor(top)');
input.style.setProperty('left', 'anchor(left)');
input.style.setProperty('width', 'anchor-size(width)');
input.style.setProperty('height', 'anchor-size(height)');
});
}
16 changes: 16 additions & 0 deletions packages/react-aria-components/stories/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,19 @@ export const CheckboxExample: CheckboxStory = {
</Checkbox>
)
};

// Demonstrates the screen reader focus indicator tracking the checkbox. The
// hidden input is anchored to the component's outer element via CSS anchor
// positioning, so VoiceOver/NVDA draw the ring around the visible component.
export const CheckboxScreenReaderFocusRing: CheckboxStory = {

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.

same question

render: args => (
<Checkbox {...args}>
<div className="checkbox">
<svg viewBox="0 0 18 18" aria-hidden="true">
<polyline points="1 9 7 14 15 4" />
</svg>
</div>
Unsubscribe
</Checkbox>
)
};
19 changes: 19 additions & 0 deletions packages/react-aria-components/stories/RadioGroup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ export const RadioGroupExample: RadioGroupStoryObj = {
}
};

// Demonstrates the screen reader focus indicator tracking each radio. The
// hidden input is anchored to the component's outer element via CSS anchor
// positioning, so VoiceOver/NVDA draw the ring around the visible component.
export const RadioGroupScreenReaderFocusRing: RadioGroupStoryObj = {

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.

why do we need a separate story? won't this be on every radio and checkbox by default now?

render: props => {
return (
<RadioGroup {...props} data-testid="radio-group-focus-ring">
<Label>Favorite pet</Label>
<Radio onFocus={action('radio focus')} onBlur={action('radio blur')} value="dogs">

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.

why do we need focus and blur actions?

Dog
</Radio>
<Radio onFocus={action('radio focus')} onBlur={action('radio blur')} value="cats">
Cat
</Radio>
</RadioGroup>
);
}
};

export const RadioGroupControlledExample: RadioGroupStory = props => {
let [selected, setSelected] = useState<string | null>(null);

Expand Down
117 changes: 117 additions & 0 deletions packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

// Verifies in a real browser (not jsdom) that the hidden native input is
// anchored to the component's outer element via CSS anchor positioning, so its
// bounding box matches the visible component and the screen reader focus
// indicator aligns with the visual one.
//
// This is a layout test: jsdom does no layout, so it cannot validate this.
//
// Browsers without CSS anchor positioning support keep the default 1x1px
// VisuallyHidden behavior, in which case these tests are skipped.

import {Checkbox} from '../src/Checkbox';
import {expect, it} from 'vitest';
import {Label} from '../src/Label';
import {Radio, RadioGroup} from '../src/RadioGroup';
import React from 'react';
import {render} from 'vitest-browser-react';

function rect(el: Element) {
let r = el.getBoundingClientRect();
return {x: r.x, y: r.y, width: r.width, height: r.height};
}

function supportsAnchorPositioning() {
return CSS.supports('anchor-name: --test');
}

// The input should cover the component. It may be up to 2px larger than the
// label (subpixel rounding); that is fine for the screen reader focus ring.
function covers(a: {width: number; height: number}, b: {width: number; height: number}) {
return a.width >= b.width - 1 && a.height >= b.height - 1;
}

it('Checkbox: the hidden input covers the component via anchor positioning', async () => {
let screen = await render(<Checkbox>Test</Checkbox>);

let label = screen.container.querySelector('label')!;
let input = screen.container.querySelector('input')!;

let labelRect = rect(label);
let inputRect = rect(input);

// The visible component should be larger than 1x1.
expect(labelRect.width).toBeGreaterThan(1);
expect(labelRect.height).toBeGreaterThan(1);

if (supportsAnchorPositioning()) {
// The input should be anchored to the component's outer element, not the
// viewport and not the 1x1px VisuallyHidden wrapper.
expect(covers(inputRect, labelRect)).toBe(true);
expect(inputRect.width).toBeLessThanOrEqual(labelRect.width + 2);
expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2);

// A consumer-provided anchor-name is used instead of the component default.
let anchorName = getComputedStyle(label).getPropertyValue('anchor-name').trim();
expect(anchorName).not.toBe('');
}
});

it('Radio: the hidden input covers the component via anchor positioning', async () => {
let screen = await render(
<RadioGroup>
<Label>Test</Label>
<Radio value="a">A</Radio>
</RadioGroup>
);

// The Radio's own label is the one containing the input, not the standalone
// <Label>Test</Label> which precedes it in the DOM.
let input = screen.container.querySelector('input')!;
let label = input.closest('label')!;

let labelRect = rect(label);
let inputRect = rect(input);

expect(labelRect.width).toBeGreaterThan(1);
expect(labelRect.height).toBeGreaterThan(1);

if (supportsAnchorPositioning()) {
expect(covers(inputRect, labelRect)).toBe(true);
expect(inputRect.width).toBeLessThanOrEqual(labelRect.width + 2);
expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2);
}
});

it('the hidden input respects a custom anchor-name provided via CSS', async () => {
let screen = await render(
<Checkbox style={{position: 'relative', ['anchorName' as any]: '--custom-anchor'}}>
Test
</Checkbox>
);

let input = screen.container.querySelector('input')!;
let label = screen.container.querySelector('label')!;

if (supportsAnchorPositioning()) {
expect(getComputedStyle(label).getPropertyValue('anchor-name').trim()).toBe('--custom-anchor');
expect(getComputedStyle(input).getPropertyValue('position-anchor').trim()).toBe(
'--custom-anchor'
);

let labelRect = rect(label);
let inputRect = rect(input);
expect(covers(inputRect, labelRect)).toBe(true);
}
});
29 changes: 29 additions & 0 deletions packages/react-aria-components/test/Checkbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,22 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => {
expect(inputRef.current).toBe(getByRole('checkbox'));
});

it('should anchor the hidden input to the component in supporting browsers', () => {
let {getByRole} = render(<Checkbox>Test</Checkbox>);
let checkbox = getByRole('checkbox');
if (
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('anchor-name: --test')
) {
expect(checkbox).toHaveStyle('position: fixed');
expect(checkbox).toHaveStyle('position-anchor: --react-aria-checkbox-1');
expect(checkbox).toHaveStyle('top: anchor(top)');
expect(checkbox).toHaveStyle('width: anchor-size(width)');
expect(checkbox).toHaveStyle('height: anchor-size(height)');
}
});

it('should support callback ref', () => {
let cleanup = jest.fn();
let onRef = jest.fn(() => cleanup);
Expand Down Expand Up @@ -491,3 +507,16 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});

describe('CheckboxButton', function () {
it('renders the hidden input inside VisuallyHidden by default', () => {
let {getByRole} = render(
<CheckboxField>
<CheckboxButton>Test</CheckboxButton>
</CheckboxField>
);
let checkboxInput = getByRole('checkbox');
let checkboxLabel = checkboxInput.closest('label');
expect(checkboxLabel).not.toHaveStyle('position: fixed');
});
});
Loading
Loading