A practical guide to identifying and fixing the most common issues when implementing RTL support.

Karim Benali
Senior frontend developer with 10+ years building RTL-first applications.
You've added dir="rtl" to your HTML, but something looks wrong. Text is mirrored, but buttons are in weird places, icons point the wrong way, and that dropdown menu opens off-screen.
RTL bugs are frustrating because they're often subtle and hard to spot if you don't read the language. This guide catalogs the most common RTL issues with clear before/after examples and tested solutions.
Directional icons (arrows, carets, chevrons) still point left when they should point right:
LTR: Next →
RTL: التالي → ← Should be: ← التاليOption 1: Use CSS Transform
[dir="rtl"] .icon-arrow {
transform: scaleX(-1);
}Option 2: Use Logical Icon Sets
<!-- Instead of left/right-specific icons -->
<span class="icon-chevron-start"></span>
<span class="icon-chevron-end"></span>.icon-chevron-start::before {
content: url('chevron-left.svg');
}
.icon-chevron-end::before {
content: url('chevron-right.svg');
}
[dir="rtl"] .icon-chevron-start::before {
content: url('chevron-right.svg');
}
[dir="rtl"] .icon-chevron-end::before {
content: url('chevron-left.svg');
}Option 3: CSS Logical Properties for Background
.icon {
background-image: url('arrow.svg');
}
/* Future CSS (limited support) */
.icon {
background-position-inline: start;
}Keep these consistent regardless of direction:
Truncated text shows ellipsis on the wrong side:
LTR: This is a very long t...
RTL: ...هذا نص طويل جداً ي ← Wrong side!.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start; /* Not 'left' */
}For multi-line truncation:
.line-clamp {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: start;
}Labels and inputs don't align properly:
LTR: [Label] [____________]
RTL: [____________] [Label] ← Looks backwardsUse logical properties and flexbox:
.form-group {
display: flex;
align-items: center;
gap: 12px;
}
.form-label {
/* Logical properties handle direction */
text-align: start;
min-inline-size: 100px;
}
.form-input {
flex: 1;
text-align: start;
padding-inline: 12px;
}For stacked layouts:
.form-group-stacked {
display: flex;
flex-direction: column;
align-items: stretch;
}
.form-label {
margin-block-end: 8px;
text-align: start;
}Dropdowns, tooltips, and modals appear in wrong positions:
/* This breaks in RTL */
.dropdown {
position: absolute;
left: 0;
top: 100%;
}Option 1: Logical Properties
.dropdown {
position: absolute;
inset-inline-start: 0;
inset-block-start: 100%;
}Option 2: Direction-Aware JavaScript
function positionDropdown(trigger, dropdown) {
const isRTL = getComputedStyle(document.documentElement).direction === 'rtl';
const rect = trigger.getBoundingClientRect();
dropdown.style.top = `${rect.bottom}px`;
if (isRTL) {
dropdown.style.right = `${window.innerWidth - rect.right}px`;
dropdown.style.left = 'auto';
} else {
dropdown.style.left = `${rect.left}px`;
dropdown.style.right = 'auto';
}
}Horizontal scrolling doesn't work correctly:
// This scrolls wrong direction in RTL
element.scrollLeft = 100;RTL scroll behavior varies by browser. Use this normalizer:
function setScrollStart(element, value) {
const isRTL = getComputedStyle(element).direction === 'rtl';
if (!isRTL) {
element.scrollLeft = value;
return;
}
// Browser detection for RTL scroll behavior
// Firefox: negative values
// Chrome/Safari: positive values from right
const maxScroll = element.scrollWidth - element.clientWidth;
// Normalize to work across browsers
element.scrollLeft = maxScroll - value;
}
function getScrollStart(element) {
const isRTL = getComputedStyle(element).direction === 'rtl';
if (!isRTL) {
return element.scrollLeft;
}
const maxScroll = element.scrollWidth - element.clientWidth;
return maxScroll - Math.abs(element.scrollLeft);
}Slide-in animations come from the wrong side:
/* Slides from left - wrong for RTL! */
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}Option 1: CSS Custom Properties
:root {
--slide-from: -100%;
}
[dir="rtl"] {
--slide-from: 100%;
}
@keyframes slideIn {
from { transform: translateX(var(--slide-from)); }
to { transform: translateX(0); }
}Option 2: JavaScript-Controlled Animation
function getSlideDirection() {
const isRTL = document.documentElement.dir === 'rtl';
return isRTL ? '100%' : '-100%';
}
element.animate([
{ transform: `translateX(${getSlideDirection()})` },
{ transform: 'translateX(0)' }
], {
duration: 300,
easing: 'ease-out'
});Asymmetric border radius applies to wrong corners:
/* Rounds top-left and bottom-left */
.card {
border-radius: 8px 0 0 8px;
}
/* In RTL, you probably want top-right and bottom-right */.card {
border-start-start-radius: 8px;
border-end-start-radius: 8px;
border-start-end-radius: 0;
border-end-end-radius: 0;
}Logical border-radius mapping:
| Physical | Logical |
|---|---|
| top-left | start-start |
| top-right | start-end |
| bottom-left | end-start |
| bottom-right | end-end |
Box shadows that simulate depth look wrong when mirrored:
/* Shadow on right side */
.card {
box-shadow: 5px 0 10px rgba(0,0,0,0.2);
}
/* In RTL, shadow should probably be on left */Box shadows represent light source, which is often physical. But for UI-indicating shadows:
:root {
--shadow-offset-x: 5px;
}
[dir="rtl"] {
--shadow-offset-x: -5px;
}
.card {
box-shadow: var(--shadow-offset-x) 0 10px rgba(0,0,0,0.2);
}Mixed LTR/RTL text breaks layout:
<p dir="rtl">البريد الإلكتروني: user@example.com</p>
<!-- Email may appear in unexpected position -->Use <bdi> or dir="ltr" on embedded content:
<p dir="rtl">
البريد الإلكتروني: <bdi dir="ltr">user@example.com</bdi>
</p>Or use Unicode isolates in JavaScript:
const LRI = '\u2066'; // Left-to-Right Isolate
const PDI = '\u2069'; // Pop Directional Isolate
const text = `البريد الإلكتروني: ${LRI}${email}${PDI}`;Tables don't reverse column order in RTL:
<table>
<tr>
<td>First</td>
<td>Second</td>
<td>Third</td>
</tr>
</table>Tables should automatically reverse with dir="rtl" on the container. If not:
[dir="rtl"] table {
direction: rtl;
}
[dir="rtl"] th,
[dir="rtl"] td {
text-align: start;
}For more control, use flexbox or grid instead of tables for layout:
.data-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* Automatically reverses in RTL */Progress fills from the wrong side:
.progress-fill {
width: 60%;
/* Fills from left, should fill from right in RTL */
}.progress-bar {
display: flex;
}
.progress-fill {
/* Flexbox handles direction */
height: 100%;
background: var(--accent);
}
/* Or with explicit positioning */
.progress-fill {
position: absolute;
inset-inline-start: 0;
width: 60%;
}For range inputs:
input[type="range"] {
direction: ltr; /* Keep sliders LTR for consistency */
/* Or handle RTL specifically */
}
[dir="rtl"] input[type="range"] {
transform: scaleX(-1);
}Overlapping elements stack incorrectly when positioned with physical properties.
Ensure all positioned elements use logical properties:
.modal {
position: fixed;
inset: 0; /* Full screen */
z-index: 100;
}
.modal-content {
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}Use this checklist when testing RTL:
Test early and often: RTL bugs are easier to fix when caught early.
Use logical properties: They prevent most positioning issues automatically.
Don't flip everything: Some elements (icons, shadows) need careful consideration.
Handle BiDi text: Use <bdi> for embedded opposite-direction content.
Test with native speakers: They'll catch issues automated testing misses.
A practical guide to identifying and fixing the most common issues when implementing RTL support.

Karim Benali
Senior frontend developer with 10+ years building RTL-first applications.
You've added dir="rtl" to your HTML, but something looks wrong. Text is mirrored, but buttons are in weird places, icons point the wrong way, and that dropdown menu opens off-screen.
RTL bugs are frustrating because they're often subtle and hard to spot if you don't read the language. This guide catalogs the most common RTL issues with clear before/after examples and tested solutions.
Directional icons (arrows, carets, chevrons) still point left when they should point right:
LTR: Next →
RTL: التالي → ← Should be: ← التاليOption 1: Use CSS Transform
[dir="rtl"] .icon-arrow {
transform: scaleX(-1);
}Option 2: Use Logical Icon Sets
<!-- Instead of left/right-specific icons -->
<span class="icon-chevron-start"></span>
<span class="icon-chevron-end"></span>.icon-chevron-start::before {
content: url('chevron-left.svg');
}
.icon-chevron-end::before {
content: url('chevron-right.svg');
}
[dir="rtl"] .icon-chevron-start::before {
content: url('chevron-right.svg');
}
[dir="rtl"] .icon-chevron-end::before {
content: url('chevron-left.svg');
}Option 3: CSS Logical Properties for Background
.icon {
background-image: url('arrow.svg');
}
/* Future CSS (limited support) */
.icon {
background-position-inline: start;
}Keep these consistent regardless of direction:
Truncated text shows ellipsis on the wrong side:
LTR: This is a very long t...
RTL: ...هذا نص طويل جداً ي ← Wrong side!.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start; /* Not 'left' */
}For multi-line truncation:
.line-clamp {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: start;
}Labels and inputs don't align properly:
LTR: [Label] [____________]
RTL: [____________] [Label] ← Looks backwardsUse logical properties and flexbox:
.form-group {
display: flex;
align-items: center;
gap: 12px;
}
.form-label {
/* Logical properties handle direction */
text-align: start;
min-inline-size: 100px;
}
.form-input {
flex: 1;
text-align: start;
padding-inline: 12px;
}For stacked layouts:
.form-group-stacked {
display: flex;
flex-direction: column;
align-items: stretch;
}
.form-label {
margin-block-end: 8px;
text-align: start;
}Dropdowns, tooltips, and modals appear in wrong positions:
/* This breaks in RTL */
.dropdown {
position: absolute;
left: 0;
top: 100%;
}Option 1: Logical Properties
.dropdown {
position: absolute;
inset-inline-start: 0;
inset-block-start: 100%;
}Option 2: Direction-Aware JavaScript
function positionDropdown(trigger, dropdown) {
const isRTL = getComputedStyle(document.documentElement).direction === 'rtl';
const rect = trigger.getBoundingClientRect();
dropdown.style.top = `${rect.bottom}px`;
if (isRTL) {
dropdown.style.right = `${window.innerWidth - rect.right}px`;
dropdown.style.left = 'auto';
} else {
dropdown.style.left = `${rect.left}px`;
dropdown.style.right = 'auto';
}
}Horizontal scrolling doesn't work correctly:
// This scrolls wrong direction in RTL
element.scrollLeft = 100;RTL scroll behavior varies by browser. Use this normalizer:
function setScrollStart(element, value) {
const isRTL = getComputedStyle(element).direction === 'rtl';
if (!isRTL) {
element.scrollLeft = value;
return;
}
// Browser detection for RTL scroll behavior
// Firefox: negative values
// Chrome/Safari: positive values from right
const maxScroll = element.scrollWidth - element.clientWidth;
// Normalize to work across browsers
element.scrollLeft = maxScroll - value;
}
function getScrollStart(element) {
const isRTL = getComputedStyle(element).direction === 'rtl';
if (!isRTL) {
return element.scrollLeft;
}
const maxScroll = element.scrollWidth - element.clientWidth;
return maxScroll - Math.abs(element.scrollLeft);
}Slide-in animations come from the wrong side:
/* Slides from left - wrong for RTL! */
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}Option 1: CSS Custom Properties
:root {
--slide-from: -100%;
}
[dir="rtl"] {
--slide-from: 100%;
}
@keyframes slideIn {
from { transform: translateX(var(--slide-from)); }
to { transform: translateX(0); }
}Option 2: JavaScript-Controlled Animation
function getSlideDirection() {
const isRTL = document.documentElement.dir === 'rtl';
return isRTL ? '100%' : '-100%';
}
element.animate([
{ transform: `translateX(${getSlideDirection()})` },
{ transform: 'translateX(0)' }
], {
duration: 300,
easing: 'ease-out'
});Asymmetric border radius applies to wrong corners:
/* Rounds top-left and bottom-left */
.card {
border-radius: 8px 0 0 8px;
}
/* In RTL, you probably want top-right and bottom-right */.card {
border-start-start-radius: 8px;
border-end-start-radius: 8px;
border-start-end-radius: 0;
border-end-end-radius: 0;
}Logical border-radius mapping:
| Physical | Logical |
|---|---|
| top-left | start-start |
| top-right | start-end |
| bottom-left | end-start |
| bottom-right | end-end |
Box shadows that simulate depth look wrong when mirrored:
/* Shadow on right side */
.card {
box-shadow: 5px 0 10px rgba(0,0,0,0.2);
}
/* In RTL, shadow should probably be on left */Box shadows represent light source, which is often physical. But for UI-indicating shadows:
:root {
--shadow-offset-x: 5px;
}
[dir="rtl"] {
--shadow-offset-x: -5px;
}
.card {
box-shadow: var(--shadow-offset-x) 0 10px rgba(0,0,0,0.2);
}Mixed LTR/RTL text breaks layout:
<p dir="rtl">البريد الإلكتروني: user@example.com</p>
<!-- Email may appear in unexpected position -->Use <bdi> or dir="ltr" on embedded content:
<p dir="rtl">
البريد الإلكتروني: <bdi dir="ltr">user@example.com</bdi>
</p>Or use Unicode isolates in JavaScript:
const LRI = '\u2066'; // Left-to-Right Isolate
const PDI = '\u2069'; // Pop Directional Isolate
const text = `البريد الإلكتروني: ${LRI}${email}${PDI}`;Tables don't reverse column order in RTL:
<table>
<tr>
<td>First</td>
<td>Second</td>
<td>Third</td>
</tr>
</table>Tables should automatically reverse with dir="rtl" on the container. If not:
[dir="rtl"] table {
direction: rtl;
}
[dir="rtl"] th,
[dir="rtl"] td {
text-align: start;
}For more control, use flexbox or grid instead of tables for layout:
.data-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* Automatically reverses in RTL */Progress fills from the wrong side:
.progress-fill {
width: 60%;
/* Fills from left, should fill from right in RTL */
}.progress-bar {
display: flex;
}
.progress-fill {
/* Flexbox handles direction */
height: 100%;
background: var(--accent);
}
/* Or with explicit positioning */
.progress-fill {
position: absolute;
inset-inline-start: 0;
width: 60%;
}For range inputs:
input[type="range"] {
direction: ltr; /* Keep sliders LTR for consistency */
/* Or handle RTL specifically */
}
[dir="rtl"] input[type="range"] {
transform: scaleX(-1);
}Overlapping elements stack incorrectly when positioned with physical properties.
Ensure all positioned elements use logical properties:
.modal {
position: fixed;
inset: 0; /* Full screen */
z-index: 100;
}
.modal-content {
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}Use this checklist when testing RTL:
Test early and often: RTL bugs are easier to fix when caught early.
Use logical properties: They prevent most positioning issues automatically.
Don't flip everything: Some elements (icons, shadows) need careful consideration.
Handle BiDi text: Use <bdi> for embedded opposite-direction content.
Test with native speakers: They'll catch issues automated testing misses.