diff --git a/packages/react-aria-components/src/Slider.tsx b/packages/react-aria-components/src/Slider.tsx
index 7129ae20c18..0310c14680c 100644
--- a/packages/react-aria-components/src/Slider.tsx
+++ b/packages/react-aria-components/src/Slider.tsx
@@ -47,6 +47,7 @@ import React, {
import {SliderState, useSliderState} from 'react-stately/useSliderState';
import {useFocusRing} from 'react-aria/useFocusRing';
import {useHover} from 'react-aria/useHover';
+import {useLocale} from 'react-aria/I18nProvider';
import {useNumberFormatter} from 'react-aria/useNumberFormatter';
import {VisuallyHidden} from 'react-aria/VisuallyHidden';
@@ -423,6 +424,13 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function
let endPercent = Math.max(start, end);
let sizePercent = Math.max(0, endPercent - startPercent);
+ // The thumb is positioned with the physical `left` property against the direction from
+ // `useLocale`, so the fill has to be anchored physically against the same direction.
+ // `insetInlineStart` resolves against the document instead, which disagrees whenever an
+ // `I18nProvider` overrides the direction, putting the fill and the thumb on opposite edges.
+ let {direction} = useLocale();
+ let fillInset = direction === 'rtl' ? 'right' : 'left';
+
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-SliderFill',
@@ -436,7 +444,7 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function
}
: {
position: 'absolute',
- insetInlineStart: `${startPercent}%`,
+ [fillInset]: `${startPercent}%`,
width: `${sizePercent}%`,
height: '100%'
},
diff --git a/packages/react-aria-components/stories/Slider.stories.tsx b/packages/react-aria-components/stories/Slider.stories.tsx
index c8f77cc2f02..5503043988e 100644
--- a/packages/react-aria-components/stories/Slider.stories.tsx
+++ b/packages/react-aria-components/stories/Slider.stories.tsx
@@ -10,11 +10,12 @@
* governing permissions and limitations under the License.
*/
+import {I18nProvider} from 'react-aria/I18nProvider';
import {Label} from '../src/Label';
import {Meta, StoryFn} from '@storybook/react';
import React from 'react';
-import {Slider, SliderOutput, SliderThumb, SliderTrack} from '../src/Slider';
+import {Slider, SliderFill, SliderOutput, SliderThumb, SliderTrack} from '../src/Slider';
import styles from '../example/index.css';
import './styles.css';
@@ -119,3 +120,69 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod
);
};
+
+export const SliderOverriddenDirection: SliderStory = () => (
+ // A control whose direction comes from its content rather than from the reading order is wrapped
+ // in an `I18nProvider` that overrides the direction. Left: an RTL locale, where a playback bar
+ // stays left to right. Right: an LTR locale, where a manga page position bar runs right to left.
+ // Each is shown next to a control that follows the locale. The wrapper also sets `dir`, so that
+ // CSS logical properties inside it resolve the same way the components do.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js
index aa40ce9b5a0..487d0f35fae 100644
--- a/packages/react-aria-components/test/Slider.test.js
+++ b/packages/react-aria-components/test/Slider.test.js
@@ -11,6 +11,7 @@
*/
import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
+import {I18nProvider} from 'react-aria/I18nProvider';
import {Label} from '../src/Label';
import React, {useState} from 'react';
import {
@@ -405,7 +406,7 @@ describe('Slider', () => {
expect(fill).toHaveAttribute('data-orientation', 'horizontal');
expect(fill).toHaveStyle({
position: 'absolute',
- insetInlineStart: '0%',
+ left: '0%',
width: '30%',
height: '100%'
});
@@ -420,7 +421,7 @@ describe('Slider', () => {
expect(fill).toHaveAttribute('data-orientation', 'horizontal');
expect(fill).toHaveStyle({
position: 'absolute',
- insetInlineStart: '30%',
+ left: '30%',
width: '20%',
height: '100%'
});
@@ -428,7 +429,7 @@ describe('Slider', () => {
rerender();
expect(fill).toHaveStyle({
position: 'absolute',
- insetInlineStart: '50%',
+ left: '50%',
width: '30%',
height: '100%'
});
@@ -456,4 +457,63 @@ describe('Slider', () => {
);
expect(fill).toHaveStyle({position: 'absolute', bottom: '50%', height: '30%', width: '100%'});
});
+
+ describe('direction', () => {
+ function renderWithDirection(locale, direction, sliderProps) {
+ return render(
+
+
+
+ );
+ }
+
+ function parts(getByRole) {
+ let group = getByRole('group');
+ return {
+ fill: group.querySelector('.react-aria-SliderFill'),
+ thumb: group.querySelector('.react-aria-SliderThumb')
+ };
+ }
+
+ // The fill is anchored with a physical property rather than `insetInlineStart`, because
+ // the thumb is positioned with a physical `left` and the two have to agree. A logical
+ // property would resolve against the document, which an I18nProvider override does not change.
+ it('anchors the fill and the thumb to the same edge in an LTR locale', () => {
+ let {fill, thumb} = parts(renderWithDirection('en-US', undefined).getByRole);
+ expect(fill).toHaveStyle({left: '0%', width: '30%'});
+ expect(fill.style.insetInlineStart).toBe('');
+ expect(fill.style.right).toBe('');
+ expect(thumb).toHaveStyle({left: '30%'});
+ });
+
+ it('anchors the fill and the thumb to the same edge in an RTL locale', () => {
+ let {fill, thumb} = parts(renderWithDirection('ar-AE', undefined).getByRole);
+ expect(fill).toHaveStyle({right: '0%', width: '30%'});
+ expect(fill.style.insetInlineStart).toBe('');
+ expect(fill.style.left).toBe('');
+ expect(thumb).toHaveStyle({left: '70%'});
+ });
+
+ it('follows an ltr override inside an RTL locale', () => {
+ let {fill, thumb} = parts(renderWithDirection('ar-AE', 'ltr').getByRole);
+ expect(fill).toHaveStyle({left: '0%', width: '30%'});
+ expect(fill.style.right).toBe('');
+ expect(thumb).toHaveStyle({left: '30%'});
+ });
+
+ // The case a boolean could not express: content that reads right to left inside an LTR UI.
+ it('follows an rtl override inside an LTR locale', () => {
+ let {fill, thumb} = parts(renderWithDirection('en-US', 'rtl').getByRole);
+ expect(fill).toHaveStyle({right: '0%', width: '30%'});
+ expect(fill.style.left).toBe('');
+ expect(thumb).toHaveStyle({left: '70%'});
+ });
+
+ it('leaves a vertical fill on the bottom edge whatever the direction', () => {
+ let {fill} = parts(renderWithDirection('ar-AE', 'ltr', {orientation: 'vertical'}).getByRole);
+ expect(fill).toHaveStyle({bottom: '0%', height: '30%'});
+ expect(fill.style.left).toBe('');
+ expect(fill.style.right).toBe('');
+ });
+ });
});
diff --git a/packages/react-aria/src/i18n/I18nProvider.tsx b/packages/react-aria/src/i18n/I18nProvider.tsx
index 61ea62a098f..272885602fb 100644
--- a/packages/react-aria/src/i18n/I18nProvider.tsx
+++ b/packages/react-aria/src/i18n/I18nProvider.tsx
@@ -27,6 +27,16 @@ export interface I18nProviderProps {
children: ReactNode;
/** The locale to apply to the children. */
locale?: string;
+ /**
+ * The writing direction to apply to the children, overriding the direction implied by the
+ * locale. Use this for content whose direction comes from the content itself rather than from
+ * the reading order of the UI, such as a media playback bar that stays `'ltr'` in an RTL UI.
+ * Set `dir` on an element inside the provider as well, so that CSS logical properties resolve
+ * the same way the components do.
+ *
+ * @default the direction implied by the locale
+ */
+ direction?: Direction;
}
const I18nContext = React.createContext(null);
@@ -39,13 +49,13 @@ interface I18nProviderWithLocaleProps extends I18nProviderProps {
* Internal component that handles the case when locale is provided.
*/
function I18nProviderWithLocale(props: I18nProviderWithLocaleProps): JSX.Element {
- let {locale, children} = props;
+ let {locale, direction, children} = props;
let value: Locale = React.useMemo(
() => ({
locale,
- direction: isRTL(locale) ? 'rtl' : 'ltr'
+ direction: direction ?? (isRTL(locale) ? 'rtl' : 'ltr')
}),
- [locale]
+ [locale, direction]
);
return {children};
@@ -53,31 +63,36 @@ function I18nProviderWithLocale(props: I18nProviderWithLocaleProps): JSX.Element
interface I18nProviderWithDefaultLocaleProps {
children: ReactNode;
+ direction?: Direction;
}
/**
* Internal component that handles the case when no locale is provided.
*/
function I18nProviderWithDefaultLocale(props: I18nProviderWithDefaultLocaleProps): JSX.Element {
- let {children} = props;
+ let {direction, children} = props;
let defaultLocale = useDefaultLocale();
+ let value: Locale = React.useMemo(
+ () => (direction ? {locale: defaultLocale.locale, direction} : defaultLocale),
+ [defaultLocale, direction]
+ );
- return {children};
+ return {children};
}
/**
* Provides the locale for the application to all child components.
*/
export function I18nProvider(props: I18nProviderProps): JSX.Element {
- let {locale, children} = props;
+ let {locale, direction, children} = props;
// Conditionally render different components to avoid calling useDefaultLocale.
// This is necessary because useDefaultLocale triggers a re-render.
if (locale) {
- return ;
+ return ;
}
- return ;
+ return ;
}
/**
diff --git a/packages/react-aria/test/i18n/I18nProvider.test.js b/packages/react-aria/test/i18n/I18nProvider.test.js
new file mode 100644
index 00000000000..6182cc1e4a2
--- /dev/null
+++ b/packages/react-aria/test/i18n/I18nProvider.test.js
@@ -0,0 +1,83 @@
+/*
+ * 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 {I18nProvider, useLocale} from '../../src/i18n/I18nProvider';
+import React from 'react';
+import {render, screen} from '@react-spectrum/test-utils-internal';
+
+function TestComponent() {
+ let {locale, direction} = useLocale();
+ return (
+
+
{locale}
+
{direction}
+
+ );
+}
+
+function renderProvider(props) {
+ render(
+
+
+
+ );
+ return {
+ locale: screen.getByTestId('locale').textContent,
+ direction: screen.getByTestId('direction').textContent
+ };
+}
+
+describe('I18nProvider direction', () => {
+ it('takes rtl from an RTL locale when no direction is given', () => {
+ expect(renderProvider({locale: 'ar-AE'})).toEqual({locale: 'ar-AE', direction: 'rtl'});
+ });
+
+ it('takes ltr from an LTR locale when no direction is given', () => {
+ expect(renderProvider({locale: 'en-US'})).toEqual({locale: 'en-US', direction: 'ltr'});
+ });
+
+ it('overrides an RTL locale with ltr', () => {
+ expect(renderProvider({locale: 'ar-AE', direction: 'ltr'})).toEqual({
+ locale: 'ar-AE',
+ direction: 'ltr'
+ });
+ });
+
+ it('overrides an LTR locale with rtl', () => {
+ expect(renderProvider({locale: 'en-US', direction: 'rtl'})).toEqual({
+ locale: 'en-US',
+ direction: 'rtl'
+ });
+ });
+
+ it('leaves the locale itself alone', () => {
+ // The override is about layout only. Formatters and translated strings still follow
+ // the locale, so a date inside an ltr-pinned Arabic subtree is still formatted in Arabic.
+ expect(renderProvider({locale: 'ar-AE', direction: 'ltr'}).locale).toBe('ar-AE');
+ });
+
+ it('overrides the default locale without being given one', () => {
+ let {direction} = renderProvider({direction: 'rtl'});
+ expect(direction).toBe('rtl');
+ });
+
+ it('restores the locale direction in a nested provider', () => {
+ render(
+
+
+
+
+
+ );
+ expect(screen.getByTestId('direction')).toHaveTextContent('rtl');
+ });
+});
diff --git a/packages/react-aria/test/slider/useSlider.test.js b/packages/react-aria/test/slider/useSlider.test.js
index 22b4a8519f6..3e79f7c9564 100644
--- a/packages/react-aria/test/slider/useSlider.test.js
+++ b/packages/react-aria/test/slider/useSlider.test.js
@@ -6,6 +6,7 @@ import {
renderHook,
screen
} from '@react-spectrum/test-utils-internal';
+import {I18nProvider} from '../../src/i18n/I18nProvider';
import * as React from 'react';
import {useRef} from 'react';
import {useSlider} from '../../src/slider/useSlider';
@@ -315,6 +316,9 @@ describe('useSlider', () => {
expect(onChangeSpy).toHaveBeenLastCalledWith([20, 40]);
expect(onChangeEndSpy).not.toHaveBeenCalled();
expect(stateRef.current.values).toEqual([20, 40]);
+
+ // Release the pointer so the global listeners installed on pointer down are removed.
+ fireEvent.pointerUp(track, {pageX: 20, clientX: 20});
});
it('should allow you to set value of after thumbs when thumbs stacked', () => {
@@ -335,6 +339,9 @@ describe('useSlider', () => {
expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]);
expect(onChangeEndSpy).not.toHaveBeenCalled();
expect(stateRef.current.values).toEqual([40, 60]);
+
+ // Release the pointer so the global listeners installed on pointer down are removed.
+ fireEvent.pointerUp(track, {pageX: 60, clientX: 60});
});
it('should allow you to set value of before thumbs when many thumbs and stacked', () => {
@@ -423,4 +430,71 @@ describe('useSlider', () => {
expect(stateRef.current.values).toEqual([10, 80]);
});
});
+
+ describe('direction', () => {
+ let widthStub;
+ beforeAll(() => {
+ widthStub = jest
+ .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100}));
+ });
+ afterAll(() => {
+ widthStub.mockReset();
+ });
+
+ installMouseEvent();
+
+ let stateRef = React.createRef();
+
+ function Example(props) {
+ let trackRef = useRef(null);
+ let state = useSliderState({...props, numberFormatter});
+ stateRef.current = state;
+ let {trackProps} = useSlider(props, state, trackRef);
+ return ;
+ }
+
+ function clickTrackAt(locale, direction, clientX) {
+ render(
+
+
+
+ );
+ let track = screen.getByTestId('track');
+ fireEvent.mouseDown(track, {clientX, pageX: clientX});
+ fireEvent.mouseUp(track, {clientX, pageX: clientX});
+ }
+
+ // 25 is used rather than 50 so that the mirrored and non-mirrored results differ.
+ it('mirrors a track click in an RTL locale by default', () => {
+ clickTrackAt('ar-AE', undefined, 25);
+ expect(stateRef.current.values).toEqual([75]);
+ });
+
+ it('does not mirror a track click in an LTR locale by default', () => {
+ clickTrackAt('en-US', undefined, 25);
+ expect(stateRef.current.values).toEqual([25]);
+ });
+
+ it('does not mirror a track click in an RTL locale under an ltr I18nProvider', () => {
+ clickTrackAt('ar-AE', 'ltr', 25);
+ expect(stateRef.current.values).toEqual([25]);
+ });
+
+ // The case a boolean could not express: content that reads right to left inside an LTR UI.
+ it('mirrors a track click in an LTR locale under an rtl I18nProvider', () => {
+ clickTrackAt('en-US', 'rtl', 25);
+ expect(stateRef.current.values).toEqual([75]);
+ });
+
+ it('is a no-op when the override matches the locale', () => {
+ clickTrackAt('en-US', 'ltr', 25);
+ expect(stateRef.current.values).toEqual([25]);
+ });
+
+ it('is a no-op when the override matches an RTL locale', () => {
+ clickTrackAt('ar-AE', 'rtl', 25);
+ expect(stateRef.current.values).toEqual([75]);
+ });
+ });
});
diff --git a/packages/react-aria/test/slider/useSliderThumb.test.js b/packages/react-aria/test/slider/useSliderThumb.test.js
index e65a4ae684f..572f38e9ac3 100644
--- a/packages/react-aria/test/slider/useSliderThumb.test.js
+++ b/packages/react-aria/test/slider/useSliderThumb.test.js
@@ -7,6 +7,7 @@ import {
renderHook,
screen
} from '@react-spectrum/test-utils-internal';
+import {I18nProvider} from '../../src/i18n/I18nProvider';
import * as React from 'react';
import {useRef} from 'react';
import userEvent from '@testing-library/user-event';
@@ -642,4 +643,164 @@ describe('useSliderThumb', () => {
});
});
});
+
+ describe('direction', () => {
+ let widthStub;
+ beforeAll(() => {
+ widthStub = jest
+ .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100}));
+ });
+ afterAll(() => {
+ widthStub.mockReset();
+ });
+
+ installMouseEvent();
+
+ let stateRef = React.createRef();
+
+ function Example(props) {
+ let trackRef = useRef(null);
+ let inputRef = useRef(null);
+ let state = useSliderState({...props, numberFormatter});
+ stateRef.current = state;
+ let {trackProps} = useSlider(props, state, trackRef);
+ let {inputProps, thumbProps} = useSliderThumb(
+ {...props, 'aria-label': 'Value', index: 0, trackRef, inputRef},
+ state
+ );
+ return (
+
+
+
+
+
+ );
+ }
+
+ function renderSlider(locale, direction, props) {
+ return render(
+
+
+
+ );
+ }
+
+ // A value of 25 is used rather than 50 so the mirrored position (75%) and the
+ // non-mirrored position (25%) are distinguishable.
+ describe('thumb position', () => {
+ it('mirrors in an RTL locale by default', () => {
+ renderSlider('ar-AE');
+ expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'});
+ });
+
+ it('does not mirror in an LTR locale by default', () => {
+ renderSlider('en-US');
+ expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'});
+ });
+
+ it('does not mirror in an RTL locale under an ltr I18nProvider', () => {
+ renderSlider('ar-AE', 'ltr');
+ expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'});
+ });
+
+ // The case a boolean could not express: content that reads right to left inside an LTR UI.
+ it('mirrors in an LTR locale under an rtl I18nProvider', () => {
+ renderSlider('en-US', 'rtl');
+ expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'});
+ });
+
+ it('is a no-op when the override matches the locale', () => {
+ renderSlider('en-US', 'ltr');
+ expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'});
+ });
+
+ it('still runs bottom to top vertically under an ltr I18nProvider', () => {
+ renderSlider('ar-AE', 'ltr', {orientation: 'vertical'});
+ // Vertical sliders always run bottom to top, which direction must not change.
+ expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'});
+ });
+
+ it('still runs bottom to top vertically under an rtl I18nProvider', () => {
+ renderSlider('en-US', 'rtl', {orientation: 'vertical'});
+ expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'});
+ });
+ });
+
+ describe('keyboard', () => {
+ it('reverses arrow keys in an RTL locale by default', async () => {
+ let user = userEvent.setup({delay: null, pointerMap});
+ renderSlider('ar-AE');
+ await user.tab();
+ await user.keyboard('{ArrowRight}');
+ expect(stateRef.current.values).toEqual([24]);
+ await user.keyboard('{ArrowLeft}');
+ expect(stateRef.current.values).toEqual([25]);
+ });
+
+ it('does not reverse arrow keys in an RTL locale under an ltr I18nProvider', async () => {
+ let user = userEvent.setup({delay: null, pointerMap});
+ renderSlider('ar-AE', 'ltr');
+ await user.tab();
+ await user.keyboard('{ArrowRight}');
+ expect(stateRef.current.values).toEqual([26]);
+ await user.keyboard('{ArrowLeft}');
+ expect(stateRef.current.values).toEqual([25]);
+ });
+
+ it('reverses arrow keys in an LTR locale under an rtl I18nProvider', async () => {
+ let user = userEvent.setup({delay: null, pointerMap});
+ renderSlider('en-US', 'rtl');
+ await user.tab();
+ await user.keyboard('{ArrowRight}');
+ expect(stateRef.current.values).toEqual([24]);
+ await user.keyboard('{ArrowLeft}');
+ expect(stateRef.current.values).toEqual([25]);
+ });
+
+ it('does not reverse arrow keys in an LTR locale by default', async () => {
+ let user = userEvent.setup({delay: null, pointerMap});
+ renderSlider('en-US');
+ await user.tab();
+ await user.keyboard('{ArrowRight}');
+ expect(stateRef.current.values).toEqual([26]);
+ await user.keyboard('{ArrowLeft}');
+ expect(stateRef.current.values).toEqual([25]);
+ });
+ });
+
+ describe('dragging', () => {
+ function dragThumbRight() {
+ let thumb = screen.getByTestId('thumb');
+ fireEvent.mouseDown(thumb, {clientX: 25, pageX: 25});
+ fireEvent.mouseMove(thumb, {clientX: 35, pageX: 35});
+ fireEvent.mouseUp(thumb, {clientX: 35, pageX: 35});
+ }
+
+ it('reverses drag in an RTL locale by default', () => {
+ renderSlider('ar-AE');
+ dragThumbRight();
+ // Moving right decreases the value when mirrored.
+ expect(stateRef.current.values).toEqual([15]);
+ });
+
+ it('does not reverse drag in an LTR locale by default', () => {
+ renderSlider('en-US');
+ dragThumbRight();
+ expect(stateRef.current.values).toEqual([35]);
+ });
+
+ it('does not reverse drag in an RTL locale under an ltr I18nProvider', () => {
+ renderSlider('ar-AE', 'ltr');
+ dragThumbRight();
+ expect(stateRef.current.values).toEqual([35]);
+ });
+
+ it('reverses drag in an LTR locale under an rtl I18nProvider', () => {
+ renderSlider('en-US', 'rtl');
+ dragThumbRight();
+ expect(stateRef.current.values).toEqual([15]);
+ });
+ });
+ });
});