The question
Supporting a right-to-left language in Compose is supposed to be free. Set the locale to Arabic, and
Row, Start, End and padding all mirror correctly. For most of our UI, that was true.
Then the bug reports started reading like riddles. Arabic language: dragging the Voice AI pill right moves it left. Arabic: the Edu AI icon appears in the middle of the board. Arabic YouTube: the companion panel covers the video.
All three came from the same assumption, applied where it doesn’t hold.
My initial mental model
Mirroring is a property of the screen. In RTL, everything flips, and anything built from Compose’s
layout primitives flips with it. So if something ends up on the wrong side, some code must be using
Left where it meant Start.
What actually happens
Compose mirrors layouts. It has no idea which of your numbers are layout and which are physical pixels.
The overlays in our whiteboard app aren’t laid out. They’re placed. The floating Edu AI icon appears next to a piece of text the teacher wrote, at coordinates taken from that text’s glyph rectangle on the board. The Voice AI pill follows the teacher’s finger. Those numbers are physical: x = 1037 means 1037 pixels from the left edge of the panel, in every language.
But the overlays were still Compose content inside a Box, and they inherited the app’s layout direction.
Under RTL, two things change silently:
Modifier.offset(x, y)mirrors x. An offset of 1037 is applied from the right.- The
Box’sTopStartalignment moves to the top-right.
So a physical x became width − x − size.
Proving it to the pixel
The icon on a 1400-pixel-wide overlay, at a glyph x of 1037, with a 48-pixel icon, should sit at 1037. In Arabic it was drawn at:
width − x − size = 1400 − 1037 − 48 = 315
That was exactly where it was on the panel. When a bug’s position matches a formula to the pixel, the diagnosis is settled.
The Voice AI pill had the same problem in motion. Its drag delta, anchor rectangle and clamping were all physical pixels, but the offset that applied them was mirrored. Dragging right moved it left, and when you let go, it parked under the mirrored spot instead of the text box.
The surprising part
The first fix I tried was the one the API seems to offer for this: replace offset with
absoluteOffset, which doesn’t mirror.
It pushed the icon off the screen. absoluteOffset stopped the x mirroring, but the Box it lived in
still aligned its child to TopStart, which under RTL is the top-right. So the icon was placed at its
correct physical x, measured from the wrong corner.
Fixing one mirrored thing while leaving the other one in place is worse than leaving both alone. The fix that worked doesn’t patch individual modifiers. It pins the whole overlay to left-to-right:
// Simplified. The overlay places itself in physical board pixels. It has no
// directional content of its own, so it must not inherit RTL.
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
EduFloatingIcon(anchor = glyphRect) // x/y are physical pixels
EduAiModal(onResize = ::resize) // grip stays bottom-right
}
The same treatment fixed the modal’s resize grip, which had mirrored to the bottom-left while the card still grew to the right. The bottom bar and file picker already used it, which was a hint I should have taken earlier.
The third bug: someone else’s layout
The YouTube companion in our AI assistant docks a panel beside a playing video. It assumed the video was on the left, and docked on the right.
Arabic YouTube mirrors its watch page: player on the right, recommendations on the left. The docked panel covered the video, and the screenshot crop built on “the video is left of the panel” captured the recommendation column instead.
The tempting fix is “if the locale is RTL, dock left”. That’s wrong, for a subtle reason. With per-app languages, YouTube’s language can differ from the system’s and from ours. Our app could be in English while YouTube is in Arabic.
So the dock measures instead of inferring. The accessibility read that already tracks YouTube’s player every 500 ms now also records which half of the screen the player occupies. The dock goes on the other side. Until the first measurement it defaults to the right, and an unreadable frame keeps the last known answer.
Practical consequences
The rule I’d give anyone building overlays, floating windows or anything positioned by coordinates:
Layout direction belongs to content, not to things you place in pixels.
- Text, lists and toolbars inside a card should follow the locale.
- The card’s position on the screen, its drag handling and its resize grip are geometry. Pin them to LTR.
- Never infer another app’s layout from your own locale. Measure it.
A checklist that would have caught all three:
- Switch the app to an RTL language, and separately set the system and another app to RTL.
- For anything placed by coordinates, compare where it should be with where it’s drawn. If the error is
width − x − size, it’s mirroring. - Drag things. Static screenshots don’t show inverted deltas.
What I would remember
- Compose mirrors
offsetand alignment under RTL. Physical coordinates need an LTR island. - Fix mirroring at the container, not modifier by modifier.
- A position that matches
width − x − sizeexactly is a diagnosis. - Per-app languages make “the device’s language” meaningless for other apps.
- Test RTL by dragging, not just by looking.
Further reading
- Support different languages and cultures, right-to-left layouts.
LocalLayoutDirectionandModifier.absoluteOffset.- Per-app language preferences.