Skip to main content

Implementing Right-to-Left (RTL) Support in a Tailwind CSS React Application

· 11 min read
Andre Roussakoff
Maintainer of this blog

TL;DR I was developing a project for my friends when we started discussing which languages to support. "It would really be great if we had Arabic!" they said. That's when I realized I'd never dealt with Right-to-Left (RTL) languages before - and honestly, it felt a bit intimidating at first. But hey, we love a good challenge, right?

Building a web application with RTL support requires more than just translating labels and text - it requires adapting the entire user interface to be able to swap text directions. This article describes the key changes I made to seamlessly supports both LTR and RTL language like Arabic. Spoiler alert: it's actually quite doable with Tailwind CSS!

Understanding the Challenge

When adding RTL support to an existing LTR application, we need to consider:

  • Text direction: Content flows from right to left
  • Layout mirroring: UI elements should mirror horizontally, including paddings, margins, and positioning (left/right properties need to swap)
  • Icon placement: Icons and interactive elements should also be repositioned inside their containers
  • Typography: Different fonts and sizing may be needed for optimal readability
  • Form inputs: Input fields and labels need proper alignment

Compare the following two screenshots:

LTR layout

LTR Layout

RTL layout

RTL Layout

Setting Up the Foundation

1. RTL Utility Functions

The first step is creating utility functions to detect RTL languages and provide direction-aware helpers:

// app/utils/rtlUtils.ts
export const isRTL = (languageCode: string): boolean => ['ar'].includes(languageCode)

export const getDirection = (languageCode: string): 'ltr' | 'rtl' =>
isRTL(languageCode) ? 'rtl' : 'ltr'

export const getTypographyClass = (languageCode: string): string =>
isRTL(languageCode) ? 'text-arabic' : ''

Key Benefits:

  • Centralized language detection
  • Type-safe direction handling
  • Easy to extend for additional RTL languages

HTML Direction Attribute

2. Setting the dir Attribute on the HTML Tag

The dir attribute must be set on the root HTML element to enable browser-level RTL support. This is the foundation of everything else. When you set dir="rtl" on the <html> tag, the browser automatically:

  • Changes the default text direction from left-to-right to right-to-left
  • Flips the document flow (scrollbars appear on the left side)
  • Enables CSS logical properties to work correctly
  • Provides the proper context for screen readers and accessibility tools

Without this attribute, your CSS changes alone won't create a proper RTL experience. So, we can add it like this (e.g. in Remix or React Router 7 project):

// app/root.tsx
return (
<html
lang={language}
+ dir={getDirection(language)}
className='h-full overflow-x-hidden'
>
<head>...</head>
<body>...</body>
</html>
)

3. Dynamic Direction Updates

For single-page applications, the direction needs to update when the user switches languages. We can control that dynamically with direction attribute and typographyClass helper:

Click to see the full implementation code
// app/root.tsx - Declarative approach (recommended)
import { getDirection, getTypographyClass } from '~/utils/rtlUtils'

type DocumentProps = {
children: React.ReactNode
language: string
}

const Document = ({ children, language }: DocumentProps) => {
const { i18n } = useTranslation()

// Use the current language from i18n instance, falling back to initial language
const currentLanguage = i18n.language || language

// Use useState for reactive values that depend on language
const [direction, setDirection] = useState(getDirection(currentLanguage))
const [typographyClass, setTypographyClass] = useState(
getTypographyClass(currentLanguage)
)

// Update direction, typography, and cookie when language changes
useEffect(() => {
setDirection(getDirection(currentLanguage))
setTypographyClass(getTypographyClass(currentLanguage))
}, [currentLanguage, i18n.language])

return (
<html lang={currentLanguage} dir={direction} className='h-full overflow-x-hidden'>
<head>...</head>
<body
className={cn(
'bg-background text-foreground flex h-full flex-col',
typographyClass
)}
>
{children}
</body>
</html>
)
}

dir=rtl on html tag

Tailwind CSS Logical Properties

4. Replacing Physical Properties with Logical Properties

The most significant change is moving from physical properties (left/right) to logical properties (start/end):

Before (Physical Properties):

/* ❌ Physical properties - don't adapt to text direction */
.ml-4 /* margin-left */
/* margin-left */
/* margin-left */
/* margin-left */
.mr-2 /* margin-right */
.pl-3 /* padding-left */
.pr-6 /* padding-right */
.text-left;

After (Logical Properties):

/* ✅ Logical properties - automatically adapt to text direction */
.ms-4 /* margin-inline-start */
/* margin-inline-start */
/* margin-inline-start */
/* margin-inline-start */
.me-2 /* margin-inline-end */
.ps-3 /* padding-inline-start */
.pe-6 /* padding-inline-end */
.text-start;
More Information

Read more on using logical properties in the Tailwind documentation.

Component Adaptations

5. Form Input Components

Form components like combos having a chevron icon on the right to open it when in LTR, also need to be edited for proper RTL layout with the icon on the left:

// app/components/inputs/ComboField.tsx
<select
className={cn(
- 'h-12 w-full border-2 border-emerald-700/30 bg-white px-0 pl-3 pr-6 text-lg leading-6',
+ 'h-12 w-full border-2 border-emerald-700/30 bg-white px-0 ps-3 pe-6 text-lg leading-6',
)}
>
{/* options */}
</select>
- <span className='pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2'>
+ <span className='pointer-events-none absolute inset-y-0 end-0 flex items-center pe-2'>

6. Button Components with Icons

Buttons with icons need special handling to maintain proper visual hierarchy. In Latin (LTR) languages, icons typically appear to the left of the label, while in Arabic (RTL), they should appear to the right of the label to maintain natural reading flow (notice flex-row-reverse class in RTL-mode):

// app/components/buttons/DeleteButton.tsx
export function DeleteButton({ onClick, label }: DeleteButtonProps): JSX.Element {
const { i18n } = useTranslation()
const rtl = isRTL(i18n.language)

return (
<button
type='button'
onClick={onClick}
className={cn(
rtl ? 'flex-row-reverse' : 'flex-row',
'inline-flex items-center justify-center rounded-md border border-red-300',
'bg-white gap-2 px-3 py-2 text-sm font-medium text-red-700'
)}
>
(
<>
<TrashIcon className: `h-4 w-4 ${rtl ? '' : 'mr-2 -ml-1'}` />
<span>{label}</span>
</>
)}
</button>
)
}

Compare the following two screenshots:

delete button ltr delete button ltr

7. Advanced Component Helpers

For more complex components, create reusable helper functions:

// app/utils/rtlUtils.ts - Advanced helpers

// Chip/Tag component layout helper
export function getChipClasses(languageCode: string): { container: string } {
const isRtl = isRTL(languageCode)

return {
// ps-3 = more space where content starts
// pe-2 = less space where delete button is placed
container: isRtl ? 'ps-3 pe-2 gap-2 flex-row-reverse' : 'ps-3 pe-2 gap-2 flex-row',
}
}

// Dropdown menu widget positioning helper
export function getDropdownProps(languageCode: string) {
const isRtl = isRTL(languageCode)

return {
align: isRtl ? 'end' : 'start',
side: 'bottom',
sideOffset: 8,
alignOffset: isRtl ? 8 : -8,
}
}

Typography Considerations

8. Custom CSS for Arabic Typography and Mixed Content

Arabic text may require special typography treatment for optimal readability, especially when mixed with Latin text. Arabic letters appear much smaller and thinner than Latin letters at the same font size within the same font family, so we can increase the Arabic font size by 20-25% to achieve visual balance:

/* app/styles/tailwind.css */

/* Script-aware typography system */
/* Global Arabic sizing - applied to body when app language is Arabic */
.text-arabic {
font-size: 1.2em; /* Make text 20% larger for Arabic readability */
}

/* Latin text - use rem to bypass inherited em scaling from body */
.text-latin {
font-size: 0.875em; /* Make text 20$ smaller if mixing latin in rtl */
}

/* Form inputs in RTL */
[dir='rtl'] input,
[dir='rtl'] textarea,
[dir='rtl'] select {
text-align: right;
}

Practical Examples of Mixed Content

Example 1: Arabic Text Within English Documentation

When we have Arabic text within a primarily English interface, we need to ensure proper font sizing:

// Help documentation or user instructions with Arabic phrases
<div className='documentation-section'>
<p>
To create a tournament in Arabic, the interface will display{' '}
<span className='text-arabic'>إنشاء بطولة جديدة</span> (Create New Tournament). The
Arabic text maintains proper readability.
</p>
</div>

Example 2: Technical Terms in Arabic Interface

When the interface is in Arabic but contains technical terms or app names:

// Arabic interface with Latin technical terms
<div className='notification bg-blue-50 p-4' dir='rtl'>
<p className='text-arabic'>
تم حفظ الفريق بنجاح في قاعدة البيانات. يمكنك الآن الوصول إليه عبر
<span className='text-latin'>MyApp API v2.1</span>
أو من خلال لوحة التحكم.
</p>
{/*
Translation: "The team was saved successfully to the database.
We can now access it via MyApp API v2.1 or through the control panel."
*/}
</div>

9. Typography Helper Functions

// app/utils/rtlUtils.ts - Typography helpers
export function getTypographyClasses(languageCode: string): TypographyClasses {
const isRtl = isRTL(languageCode)

return {
title: isRtl ? 'leading-tight' : 'leading-normal',
heading: isRtl ? 'tracking-normal' : 'tracking-tight',
textAlign: isRtl ? 'text-right' : 'text-left',
mixedContent: isRtl ? 'leading-snug text-center' : 'leading-normal text-center',
}
}

Compare the following two screenshots:

arabic font wihout size fix arabic font with size fix

Advanced Layout Patterns

10. React Hook for RTL Support

We can create a custom hook to simplify RTL logic in components:

Click to see the full implementation code
// app/utils/rtlUtils.ts
// Helper for manual dropdown positioning with proper spacing
export function getMenuClasses(languageCode: string): MenuClasses {
const isRtl = isRTL(languageCode)

return {
// Use logical properties for spacing
spacing: isRtl ? 'me-4' : 'ms-4', // margin-inline-end : margin-inline-start
alignment: isRtl ? 'end-0' : 'start-0', // inset-inline-end : inset-inline-start
// Menu item layout - icons on correct side for RTL
menuItem: isRtl ? 'flex flex-row-reverse' : 'flex flex-row',
// Icon container positioning
iconContainer: isRtl
? 'flex w-8 items-center justify-end ps-2 pe-0 text-end' // Icon on right in RTL
: 'flex w-8 items-center justify-start ps-0 pe-2 text-start', // Icon on left in LTR
// Text container alignment
textContainer: isRtl ? 'text-right' : 'text-left',
}
}

// app/hooks/useRTLDropdown.ts
export function useRTLDropdown(): {
dropdownProps: DropdownProps
menuClasses: MenuClasses
isRTL: boolean
} {
const { i18n } = useTranslation()

return {
dropdownProps: getDropdownProps(i18n.language),
menuClasses: getMenuClasses(i18n.language),
isRTL: isRTL(i18n.language),
}
}

And then somewhere in a component:

export function MyDropdown() {
const { dropdownProps, menuClasses, isRTL } = useRTLDropdown()

return (
<DropdownMenu.Content {...dropdownProps}>
<div className={menuClasses.spacing}>{/* content */}</div>
</DropdownMenu.Content>
)
}

Testing and Validation

11. Testing Strategy

We can test our RTL-setup by checking the presence of specific classes like flex-row-reverse when in Arabic:

// app/components/buttons/__tests__/DeleteButton.test.tsx
import { render, screen } from '@testing-library/react'
import { I18nextProvider } from 'react-i18next'
import { initI18n } from '~/i18n/config'

test('renders with proper RTL classes in Arabic', () => {
const i18n = initI18n('ar')

render(
<I18nextProvider i18n={i18n}>
<DeleteButton onClick={() => {}} label='Delete' />
</I18nextProvider>
)

const button = screen.getByRole('button')
// Check that the button has flex-row-reverse for RTL
expect(button.className).toMatch(/flex-row-reverse/)
})

12. Visual Testing Checklist

No matter how well we set up our RTL implementation and test it with unit tests, we still need to visually verify that everything looks on the screen the way it should. This can be done manually or automated with end-to-end (e2e) tests. Here's a checklist of things we want to make sure look as expected:

  • Text flows in the correct direction
  • Icons appear on the correct side
  • Form inputs align properly
  • Dropdown menus position correctly
  • Navigation elements mirror appropriately
  • Scrollbars appear on the correct side
  • Modal dialogs have elements inside mirror appropriately

Pitfalls to Avoid

To be on the safe side,

  • Don't mix physical and logical properties in the same component
  • Don't forget to update the HTML dir attribute dynamically
  • Don't assume all text will automatically align correctly
  • Don't hardcode icon positions without considering RTL
  • Do test with actual RTL content, not placeholder text

Conclusion

Implementing RTL support requires systematic changes across our application, but with Tailwind CSS's logical properties and a well-structured approach, we can create truly bidirectional layouts that feel natural to users regardless of their language preference.

To achieve this we need to:

  1. Start with a solid foundation of utility functions
  2. Replace physical properties with logical properties systematically
  3. Handle complex components with conditional logic
  4. Test thoroughly with real content

This approach ensures our application provides an excellent user experience for both LTR and RTL language speakers and make our application truly international.

I hope these tips will help you to successfully implement RTL flows in your applications, inshallah!