Skip to content

Commit 41fe118

Browse files
authored
feat(ui): add RTL support for tldraw UI (tldraw#8033)
Resurrects the approach from tldraw#6682 with review feedback addressed. In order to support RTL languages like Arabic in the tldraw UI, this PR adds a `useDirection()` hook and updates all Radix UI components and CSS to respect the current locale's text direction. Screenshot: <img width="1430" height="658" alt="Screenshot 2026-03-09 at 10 19 14" src="https://github.com/user-attachments/assets/9b0e91a8-74be-4be3-93b4-caa55662ecc9" /> ### Key changes - **`useDirection()` hook**: Returns `'ltr'` or `'rtl'` from the current translation context. Exported publicly from `@tldraw/tldraw`. - **Container `dir` attribute**: Set on `.tl-container` via `useEffect` so CSS can use `.tl-container[dir='rtl']` selectors (review feedback: set `dir` on container instead of scattering it everywhere). - **Provider reordering**: `TldrawUiTranslationProvider` moved above `TldrawUiTooltipProvider` so tooltips can access direction (review feedback from tldraw#6682). - **Radix `dir` prop**: All Radix UI root components use `dir={dir}` instead of hardcoded `dir="ltr"` — dropdown menus, popovers, dialogs, selects, sliders, tooltips, context menus. Context menu trigger keeps `dir="ltr"` since the canvas is always LTR. - **Submenu chevrons**: Flip to `chevron-left` in RTL mode. - **CSS logical properties**: Converted physical properties (`margin-left`, `padding-right`, `text-align: left`, `left`/`right`) to logical equivalents (`margin-inline-start`, `padding-inline-end`, `text-align: start`, `inset-inline-start`/`inset-inline-end`). Added `.tl-container[dir='rtl']` overrides for gradients that can't use logical properties. ### Review feedback from tldraw#6682 addressed 1. ✅ Set `dir` on `.tl-container` instead of scattering `dir` attributes 2. ✅ Don't call hooks inline as props — hooks called at component level 3. ✅ Keep popover `align = 'center'` default unchanged 4. ✅ No brittle `width: 15px` on `.tlui-kbd > span` 5. ✅ Use Radix `dir` props instead of CSS `!important` overrides 6. ✅ Context menu trigger uses `dir="ltr"` (canvas is always LTR), Root uses `{dir}` 7. ✅ `TldrawUiTranslationProvider` moved above `TldrawUiTooltipProvider` ### Change type - [x] `feature` ### Test plan 1. `yarn dev` → open localhost:5420 2. Switch language to Arabic (العربية) in user preferences 3. Verify menus open on the correct side 4. Verify context menu positions correctly 5. Verify keyboard shortcuts display correctly in menus 6. Verify page menu layout mirrors 7. Verify submenus have left chevrons 8. Verify dialog titles and buttons respect direction - [x] Unit tests ### API changes - Added `useDirection()` — returns current text direction (`'ltr'` | `'rtl'`) based on locale ### Release notes - Add RTL (right-to-left) support for the tldraw UI when using Arabic and other RTL languages <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches a wide surface area of UI components and CSS, so regressions in menu positioning, animations, and resizing behavior are possible, especially in mixed LTR/RTL embedding scenarios. No auth or data-handling logic changes. > > **Overview** > Adds end-to-end RTL support by deriving a `dir` value from the active translation/locale and propagating it through the UI. > > Introduces `useDirection()` (public) plus a shared `RTL_LANGUAGES` set, sets `dir`/`lang` on the `.tl-container`, and updates Radix-based primitives (menus, popovers, dialogs, selects, sliders, tooltips, context menus) to use `dir={dir}` instead of hardcoded LTR, including flipping submenu chevrons in RTL. > > Updates styling to be direction-aware via CSS logical properties and targeted `.tl-container[dir='rtl']` overrides (e.g., sidebar/layout positioning, toast animations/gradients, watermark placement, and dotcom cookie/sidebar spacing), and reorders providers so tooltips can access translation direction. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5b11d76. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0666ccb commit 41fe118

31 files changed

Lines changed: 317 additions & 98 deletions

apps/dotcom/client/src/tla/components/TlaSidebar/sidebar.module.css

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
transition: transform 0.16s ease-in-out;
1616
}
1717

18+
:global(.tl-container[dir='rtl']) .sidebar {
19+
left: auto;
20+
right: calc(var(--tla-sidebar-width) * -1);
21+
border-right: none;
22+
border-left: 1px solid transparent;
23+
}
24+
1825
.sidebarDragOverlay {
1926
position: absolute;
2027
inset: 0px;
@@ -59,6 +66,15 @@
5966
transform: translate(100%, 0px);
6067
transition: transform 0.22s ease-out;
6168
}
69+
70+
:global(.tl-container[dir='rtl']) .sidebar {
71+
border-right: none;
72+
border-left: 1px solid var(--tl-color-low);
73+
}
74+
75+
:global(.tl-container[dir='rtl']) .sidebar[data-visible='true'] {
76+
transform: translate(-100%, 0px);
77+
}
6278
}
6379

6480
@media (max-width: 767px) {
@@ -73,6 +89,15 @@
7389
transform: translate(100%, 0px);
7490
}
7591

92+
:global(.tl-container[dir='rtl']) .sidebar {
93+
left: auto;
94+
right: calc(var(--w) * -1);
95+
}
96+
97+
:global(.tl-container[dir='rtl']) .sidebar[data-visiblemobile='true'] {
98+
transform: translate(-100%, 0px);
99+
}
100+
76101
.sidebarOverlayMobile[data-visiblemobile='true'] {
77102
display: block;
78103
}
@@ -261,6 +286,11 @@
261286
z-index: 10;
262287
}
263288

289+
:global(.tl-container[dir='rtl']) .sidebarCreateFileButton {
290+
right: auto;
291+
left: -8px;
292+
}
293+
264294
.sidebarCreateFileButton::after {
265295
display: block;
266296
content: '';
@@ -550,7 +580,7 @@
550580
border: none;
551581
position: relative;
552582
outline: none;
553-
margin-right: -10px;
583+
margin-inline-end: -10px;
554584
width: 36px;
555585
height: 36px;
556586
min-width: 0;

apps/dotcom/client/src/tla/components/dialogs/dialogs.module.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
animation-delay: 2s;
5151
}
5252

53+
:global(.tl-container[dir='rtl']) .cookieConsent {
54+
margin-right: 0;
55+
margin-left: 4px;
56+
}
57+
5358
@keyframes slideUp {
5459
0% {
5560
transform: translate(0px, 110%);
@@ -153,6 +158,11 @@
153158
width: fit-content;
154159
}
155160

161+
:global(.tl-container[dir='rtl']) .cookieConsentWrapper {
162+
right: auto;
163+
left: 0;
164+
}
165+
156166
.noPadding {
157167
padding: 0px;
158168
}

apps/dotcom/client/src/tla/layouts/TlaSidebarLayout/TlaSidebarLayout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,14 @@ export function TlaSidebarLayout({
8080

8181
if (rResizeState.current.name === 'resizing') {
8282
const { startX, startWidth } = rResizeState.current
83+
const direction = getComputedStyle(
84+
rLayoutContainer.current ?? document.documentElement
85+
).direction
86+
const deltaX = moveEvent.clientX - startX
87+
const widthDelta = direction === 'rtl' ? -deltaX : deltaX
8388

8489
const newWidth = Math.floor(
85-
clamp(startWidth + (moveEvent.clientX - startX), MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
90+
clamp(startWidth + widthDelta, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
8691
)
8792

8893
if (newWidth !== getLocalSessionStateUnsafe().sidebarWidth) {

apps/dotcom/client/src/tla/layouts/TlaSidebarLayout/sidebar-layout.module.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
transition: padding-left 0.16s ease-in-out;
1010
background-color: var(--tl-color-background);
1111
}
12+
13+
:global(.tl-container[dir='rtl']) .layout {
14+
transition: padding-right 0.16s ease-in-out;
15+
}
1216
.layout[data-resizing='true'] {
1317
pointer-events: none;
1418
transition: none !important;
@@ -21,6 +25,12 @@
2125
}
2226
}
2327

28+
:global(.tl-container[dir='rtl']) .layout[data-sidebar='true'] {
29+
padding-left: 0;
30+
padding-right: var(--tla-sidebar-width);
31+
transition: padding-right 0.22s ease-out;
32+
}
33+
2434
.resizeHandle {
2535
--indicator-width: 2px;
2636
--hoverable-width: 6px;
@@ -36,6 +46,11 @@
3646
display: none;
3747
}
3848

49+
:global(.tl-container[dir='rtl']) .resizeHandle {
50+
left: auto;
51+
right: calc(var(--tla-sidebar-width) - var(--hoverable-width) + 1px);
52+
}
53+
3954
/* make the handle bigger while mouse is down to avoid flickering */
4055
.resizeHandle:active {
4156
left: calc(
@@ -45,6 +60,13 @@
4560
display: flex;
4661
}
4762

63+
:global(.tl-container[dir='rtl']) .resizeHandle:active {
64+
left: auto;
65+
right: calc(
66+
var(--tla-sidebar-width) - (var(--active-width) / 2) - (var(--hoverable-width) / 2) + 1px
67+
);
68+
}
69+
4870
@media (min-width: 768px) {
4971
.resizeHandle {
5072
display: flex;
@@ -72,3 +94,8 @@
7294
margin-left: 4px;
7395
z-index: 1;
7496
}
97+
98+
:global(.tl-container[dir='rtl']) .toggleContainer {
99+
margin-left: 0;
100+
margin-right: 4px;
101+
}

apps/dotcom/client/src/tla/providers/TlaRootProviders.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DefaultDialogs,
1111
DefaultToasts,
1212
EditorContext,
13+
RTL_LANGUAGES,
1314
TLUiEventHandler,
1415
TldrawUiA11yProvider,
1516
TldrawUiContextProvider,
@@ -45,6 +46,11 @@ import {
4546

4647
const assetUrls = getAssetUrlsByImport()
4748

49+
function getTextDirection(locale: string): 'ltr' | 'rtl' {
50+
const [language] = locale.toLowerCase().split('-')
51+
return RTL_LANGUAGES.has(language) ? 'rtl' : 'ltr'
52+
}
53+
4854
// Override watermark URLs globally for all dotcom editors
4955
function WatermarkOverride() {
5056
useEffect(() => {
@@ -100,10 +106,12 @@ export function Component() {
100106
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>(
101107
() => getLocalSessionState().theme
102108
)
109+
const dir = getTextDirection(locale)
103110
const handleThemeChange = (theme: 'light' | 'dark' | 'system') => setTheme(theme)
104111
const handleLocaleChange = (locale: string) => {
105112
setLocale(locale)
106113
document.documentElement.lang = locale
114+
document.documentElement.dir = getTextDirection(locale)
107115
}
108116
const isFocusMode = useValue(
109117
'isFocusMode',
@@ -124,6 +132,7 @@ export function Component() {
124132
return (
125133
<div
126134
ref={setContainer}
135+
dir={dir}
127136
className={classNames(`tla tl-container tla-theme-container`, {
128137
'tla-theme__light tl-theme__light': theme === 'light',
129138
'tla-theme__dark tl-theme__dark': theme !== 'light',

apps/dotcom/client/src/tla/styles/tla.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@
243243
overflow: hidden;
244244
}
245245

246+
.tl-container[dir='rtl'] .tlui-layout__top__right > div:first-child {
247+
margin-left: 4px;
248+
}
249+
246250
.tlui-page-menu__trigger {
247251
width: fit-content;
248252
max-width: 128px;

packages/editor/src/lib/license/Watermark.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@ To remove the watermark, please purchase a license at tldraw.dev.
191191
height: 32px;
192192
}
193193
194+
.tl-container[dir='rtl'] .${className} {
195+
right: auto;
196+
left: max(var(--tl-space-2), env(safe-area-inset-left));
197+
}
198+
199+
.tl-container[dir='rtl'] .${className}[data-mobile='true'] {
200+
border-radius: 0px 4px 4px 0px;
201+
left: max(-2px, calc(env(safe-area-inset-left) - 2px));
202+
}
203+
194204
.${className}[data-unlicensed='true'] > button {
195205
font-size: 100px;
196206
position: absolute;

packages/tldraw/api-report.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2773,6 +2773,9 @@ export interface RichTextSVGProps {
27732773
// @public (undocumented)
27742774
export function RotateCWMenuItem(): JSX.Element;
27752775

2776+
// @public (undocumented)
2777+
export const RTL_LANGUAGES: Set<string>;
2778+
27762779
// @public
27772780
export function sanitizeSvg(svgText: string): string;
27782781

@@ -5566,6 +5569,9 @@ export function useDefaultHelpers(): {
55665569
// @public (undocumented)
55675570
export function useDialogs(): TLUiDialogsContextType;
55685571

5572+
// @public
5573+
export function useDirection(): "ltr" | "rtl";
5574+
55695575
// @public (undocumented)
55705576
export function useEditablePlainText(shapeId: TLShapeId, type: ExtractShapeByProps<{
55715577
text: string;

packages/tldraw/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,9 +678,10 @@ export {
678678
type TLUiToolsProviderProps,
679679
} from './lib/ui/hooks/useTools'
680680
export { type TLUiTranslationKey } from './lib/ui/hooks/useTranslation/TLUiTranslationKey'
681-
export { type TLUiTranslation } from './lib/ui/hooks/useTranslation/translations'
681+
export { RTL_LANGUAGES, type TLUiTranslation } from './lib/ui/hooks/useTranslation/translations'
682682
export {
683683
useCurrentTranslation,
684+
useDirection,
684685
useTranslation,
685686
type TLUiTranslationContextType,
686687
type TLUiTranslationProviderProps,

0 commit comments

Comments
 (0)