Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/tricky-parts-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nuka-carousel': patch
---

Add support for RTL (right-to-left) document direction.
38 changes: 38 additions & 0 deletions packages/nuka/src/Carousel/Carousel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,41 @@ export const AfterSlide: Story = {
),
},
};

const RTLRenderComponent = (props: CarouselProps) => {
const ref = useRef<SlideHandle>(null);
return (
<div dir="rtl">
<button
onClick={() => {
if (ref.current) ref.current.goBack();
}}
>
previous
</button>
<button
onClick={() => {
if (ref.current) ref.current.goForward();
}}
>
next
</button>
<Carousel ref={ref} {...props} />
</div>
);
};

export const RTL: Story = {
render: RTLRenderComponent,
args: {
scrollDistance: 'slide',
showDots: true,
children: (
<>
{[...Array(10)].map((_, index) => (
<ExampleSlide key={index} index={index} />
))}
</>
),
},
};
62 changes: 62 additions & 0 deletions packages/nuka/src/hooks/use-measurement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react';

import { useMeasurement } from './use-measurement';
import * as hooks from './use-resize-observer';
import * as browser from '../utils/browser';

const domElement = {} as any;
jest.spyOn(hooks, 'useResizeObserver').mockImplementation(() => domElement);
Expand Down Expand Up @@ -209,4 +210,65 @@ describe('useMeasurement', () => {
expect(totalPages).toBe(3);
expect(scrollOffset).toEqual([0, 200, 400]);
});

describe('RTL support', () => {
let isRTLSpy: jest.SpyInstance;

const mockElement = {
current: {
scrollWidth: 900,
offsetWidth: 500,
querySelector: () => ({
children: [
{ offsetWidth: 200 },
{ offsetWidth: 300 },
{ offsetWidth: 400 },
],
}),
},
} as any;

beforeEach(() => {
isRTLSpy = jest.spyOn(browser, 'isRTL');
});

afterEach(() => {
isRTLSpy.mockRestore();
});

it.each([
['screen', 2, [0, -500]],
['slide', 3, [0, -200, -500]],
[200, 3, [0, -200, -400]],
])(
'should return negative scroll offsets for %s mode in RTL',
(scrollDistance, expectedPages, expectedOffsets) => {
isRTLSpy.mockReturnValue(true);

const { result } = renderHook(() =>
useMeasurement({
element: mockElement,
scrollDistance: scrollDistance as any,
}),
);

expect(result.current.totalPages).toBe(expectedPages);
expect(result.current.scrollOffset).toEqual(expectedOffsets);
},
);

it('should return positive scroll offsets in LTR mode', () => {
isRTLSpy.mockReturnValue(false);

const { result } = renderHook(() =>
useMeasurement({
element: mockElement,
scrollDistance: 'screen',
}),
);

expect(result.current.totalPages).toBe(2);
expect(result.current.scrollOffset).toEqual([0, 500]);
});
});
});
32 changes: 29 additions & 3 deletions packages/nuka/src/hooks/use-measurement.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';

import { arraySeq, arraySum } from '../utils';
import { isRTL } from '../utils/browser';
import { useResizeObserver } from './use-resize-observer';

type MeasurementProps = {
Expand All @@ -27,12 +28,21 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) {

if (visibleWidth === 0) return;

const rtl = isRTL(container);

switch (scrollDistance) {
case 'screen': {
const pageCount = Math.round(scrollWidth / visibleWidth);
let offsets = arraySeq(pageCount, visibleWidth);

// In RTL mode, scroll offsets must be negative (except for the first page at 0)
// because scrollLeft uses negative values to scroll right in RTL layouts
if (rtl) {
offsets = offsets.map((offset) => (offset === 0 ? 0 : -offset));
}
Comment on lines +31 to +42
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

RTL scrollLeft semantics are not consistent across browsers (some use negative values, others use positive descending/ascending). The current approach of simply negating offsets (and the comment asserting negative scrollLeft) will be incorrect in browsers that don't use the "negative" RTL model, causing navigation to scroll to the wrong positions. Consider normalizing RTL scrollLeft behavior (detect the browser's RTL scroll model at runtime and convert logical offsets to the correct scrollLeft values) instead of assuming negatives.

Copilot uses AI. Check for mistakes.

setTotalPages(pageCount);
setScrollOffset(arraySeq(pageCount, visibleWidth));
setScrollOffset(offsets);
break;
}
case 'slide': {
Expand All @@ -51,19 +61,35 @@ export function useMeasurement({ element, scrollDistance }: MeasurementProps) {
// the remainder of the full width and window width
const pageCount =
scrollOffsets.findIndex((offset) => offset >= remainder) + 1;
let finalOffsets = scrollOffsets;

// In RTL mode, negate all offsets except the first (0) to match RTL scrollLeft behavior
if (rtl) {
finalOffsets = scrollOffsets.map((offset) =>
offset === 0 ? 0 : -offset,
);
}

setTotalPages(pageCount);
setScrollOffset(scrollOffsets);
setScrollOffset(finalOffsets);
break;
}
default: {
if (typeof scrollDistance === 'number' && scrollDistance > 0) {
// find the number of pages required to scroll all the slides
// to the end of the container
const pageCount = Math.ceil(remainder / scrollDistance) + 1;
let offsets = arraySeq(pageCount, scrollDistance);
// Clamp offsets to not exceed the total scrollable distance
offsets = offsets.map((offset) => Math.min(offset, remainder));

// Convert to negative offsets for RTL (first page stays at 0)
if (rtl) {
offsets = offsets.map((offset) => (offset === 0 ? 0 : -offset));
}

setTotalPages(pageCount);
setScrollOffset(arraySeq(pageCount, scrollDistance));
setScrollOffset(offsets);
}
}
}
Expand Down
59 changes: 59 additions & 0 deletions packages/nuka/src/utils/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { isBrowser, isRTL } from './browser';

describe('browser utils', () => {
describe('isBrowser', () => {
it('should return true in a browser environment', () => {
expect(isBrowser()).toBe(true);
});
});

describe('isRTL', () => {
let originalDir: string;

beforeEach(() => {
originalDir = document.documentElement.dir;
});

afterEach(() => {
document.documentElement.dir = originalDir;
});

it.each([
['rtl', true],
['ltr', false],
['', false],
['auto', false],
])('should return %s when document direction is "%s"', (dir, expected) => {
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The parameter placeholders in the it.each title are reversed: the table passes (dir, expected) but the title reads "should return %s when document direction is "%s"". This makes failing test output confusing; swap the placeholders or rename the parameters so the message matches what’s being asserted.

Suggested change
])('should return %s when document direction is "%s"', (dir, expected) => {
])('when document direction is "%s", should return %s', (dir, expected) => {

Copilot uses AI. Check for mistakes.
document.documentElement.dir = dir;
expect(isRTL()).toBe(expected);
});

it.each([
['rtl', true],
['ltr', false],
])(
'should detect %s from element computed style',
(dir, expected) => {
const element = document.createElement('div');
element.dir = dir;
document.body.appendChild(element);

expect(isRTL(element)).toBe(expected);

document.body.removeChild(element);
},
);

it('should inherit RTL from parent element', () => {
const parent = document.createElement('div');
parent.dir = 'rtl';
const child = document.createElement('div');
parent.appendChild(child);
document.body.appendChild(parent);

expect(isRTL(child)).toBe(true);

document.body.removeChild(parent);
});
});
});
16 changes: 16 additions & 0 deletions packages/nuka/src/utils/browser.ts
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
export const isBrowser = () => typeof window !== 'undefined';

/**
* Detects if an element or the document is in right-to-left (RTL) mode.
* Checks the computed direction style to support both document-level and element-level RTL.
* This is used to adjust scroll offsets for RTL layouts.
*/
export function isRTL(element?: HTMLElement | null) {
if (!isBrowser()) return false;

if (element) {
const direction = window.getComputedStyle(element).direction;
return direction === 'rtl';
}

Comment on lines +10 to +15
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

There appears to be trailing whitespace on the blank lines after the isBrowser() check and before the final return. This can cause lint/prettier failures; please remove the extra spaces.

Suggested change
if (element) {
const direction = window.getComputedStyle(element).direction;
return direction === 'rtl';
}
if (element) {
const direction = window.getComputedStyle(element).direction;
return direction === 'rtl';
}

Copilot uses AI. Check for mistakes.
return document.documentElement.dir === 'rtl';
Comment on lines +10 to +16
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

isRTL()'s docstring says it checks computed styles for document-level direction, but the implementation only reads document.documentElement.dir (attribute) when no element is passed. This misses cases where direction is set via CSS or inherited (e.g., body { direction: rtl; }). Consider using getComputedStyle(document.documentElement).direction (and/or document.body) for the no-element path so behavior matches the comment and is more robust.

Suggested change
if (element) {
const direction = window.getComputedStyle(element).direction;
return direction === 'rtl';
}
return document.documentElement.dir === 'rtl';
if (element) {
const direction = window.getComputedStyle(element).direction;
return direction === 'rtl';
}
if (document.documentElement.dir) {
return document.documentElement.dir === 'rtl';
}
const documentDirection =
window.getComputedStyle(document.documentElement).direction ||
(document.body ? window.getComputedStyle(document.body).direction : '');
return documentDirection === 'rtl';

Copilot uses AI. Check for mistakes.
}