How to implement RTL support in SwiftUI
SwiftUI flips .leading/.trailing alignments automatically for RTL locales. Read @Environment(\.layoutDirection) only when you need to branch logic — for example, to mirror a custom arrow icon or decide which side to anchor a swipe action.
struct BackButton: View {
@Environment(\.layoutDirection) var layoutDirection
var body: some View {
HStack(spacing: 4) {
Image(systemName: layoutDirection == .rightToLeft
? "chevron.right"
: "chevron.left")
Text("Back")
}
}
}
#Preview("RTL") {
BackButton()
.environment(\.layoutDirection, .rightToLeft)
}
Full implementation
The example below demonstrates the three most common RTL scenarios in a single screen: an HStack-based list row that auto-mirrors via semantic alignment, a custom chevron icon that reads layoutDirection explicitly, and a progress bar drawn with GeometryReader that manually accounts for direction. Two #Preview macros let you validate both directions without changing your simulator language.
import SwiftUI
// MARK: - Main screen
struct InboxView: View {
@Environment(\.layoutDirection) var layoutDirection
private let messages = Message.samples
var body: some View {
NavigationStack {
List(messages) { message in
MessageRow(message: message)
}
.navigationTitle("Inbox")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
DirectionalChevronButton(label: "Back") {}
}
}
}
}
}
// MARK: - Message row (uses semantic .leading — auto-mirrors in RTL)
struct MessageRow: View {
let message: Message
var body: some View {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(message.color)
.frame(width: 44, height: 44)
.overlay {
Text(message.initials)
.font(.headline)
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 4) { // .leading mirrors automatically
HStack {
Text(message.sender)
.font(.headline)
Spacer()
Text(message.timestamp)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(message.preview)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
ProgressBar(value: message.readProgress)
.frame(height: 4)
.padding(.top, 4)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(message.sender), \(message.preview)")
}
}
// MARK: - Chevron button (reads layoutDirection explicitly)
struct DirectionalChevronButton: View {
@Environment(\.layoutDirection) var layoutDirection
let label: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Image(systemName: layoutDirection == .rightToLeft
? "chevron.right"
: "chevron.left")
.imageScale(.small)
Text(label)
}
}
.accessibilityLabel(label)
}
}
// MARK: - Progress bar (GeometryReader — must respect direction manually)
struct ProgressBar: View {
@Environment(\.layoutDirection) var layoutDirection
let value: Double // 0.0 – 1.0
var body: some View {
GeometryReader { geo in
let filled = geo.size.width * value
ZStack(alignment: layoutDirection == .rightToLeft ? .trailing : .leading) {
Capsule().fill(Color.secondary.opacity(0.2))
Capsule()
.fill(Color.accentColor)
.frame(width: filled)
}
}
}
}
// MARK: - Model
struct Message: Identifiable {
let id = UUID()
let sender: String
let initials: String
let preview: String
let timestamp: String
let readProgress: Double
let color: Color
static let samples = [
Message(sender: "Layla Hassan", initials: "LH",
preview: "يرجى مراجعة المستند المرفق",
timestamp: "10:42", readProgress: 0.6, color: .purple),
Message(sender: "Amir Khoury", initials: "AK",
preview: "شكراً على ردك السريع",
timestamp: "09:15", readProgress: 1.0, color: .teal),
Message(sender: "Sara Nouri", initials: "SN",
preview: "هل يمكنك الحضور غداً؟",
timestamp: "Yesterday", readProgress: 0.2, color: .orange),
]
}
// MARK: - Previews
#Preview("LTR — English") {
InboxView()
.environment(\.layoutDirection, .leftToRight)
}
#Preview("RTL — Arabic") {
InboxView()
.environment(\.layoutDirection, .rightToLeft)
}
How it works
-
@Environment(\.layoutDirection)— passive detection. The property wrapper gives you aLayoutDirectionvalue (.leftToRightor.rightToLeft). The system sets it automatically based on the device locale, but you can override it per-subtree with.environment(\.layoutDirection, .rightToLeft)— useful for targeted previews and testing. -
Semantic alignment in
MessageRow— free mirroring. UsingVStack(alignment: .leading)andSpacer()insideHStackcosts nothing extra — SwiftUI interprets.leadingas "start of the reading direction," so the avatar appears on the right and text aligns right in RTL without any code change. -
Explicit branch in
DirectionalChevronButton— when semantics aren't enough. The back-navigation chevron is directionally meaningful; "back" in RTL points right, not left. ThelayoutDirection == .rightToLeftternary swaps the SF Symbol name accordingly. Many SF Symbols have built-in RTL variants — check the SF Symbols app under "Localization" before writing manual branches. -
Manual anchor in
ProgressBar—GeometryReaderis direction-agnostic. Absolute-width fills drawn withGeometryReaderalways start from a fixed origin. TheZStack(alignment:)ternary anchors the filled capsule to.trailingin RTL so the bar fills from right to left as expected. -
Dual
#Previewmacros — catch regressions instantly. Both LTR and RTL previews render simultaneously in Xcode's canvas. Because the override is injected via.environment, no scheme or simulator language change is needed — iterate fast without leaving Xcode.
Variants
Force RTL on a single view for debugging
Wrap any subtree with the environment modifier to isolate RTL testing without touching the rest of the app. This is especially handy when building a new component in isolation.
struct MyFeatureView: View {
var body: some View {
VStack {
// Normal LTR section
Text("English section")
Divider()
// RTL section — e.g. an Arabic chat bubble
ArabicChatBubble(text: "مرحباً!")
.environment(\.layoutDirection, .rightToLeft)
}
}
}
#Preview {
MyFeatureView()
}
struct ArabicChatBubble: View {
let text: String
var body: some View {
HStack {
Spacer()
Text(text)
.padding(12)
.background(.blue, in: RoundedRectangle(cornerRadius: 16))
.foregroundStyle(.white)
}
.padding(.horizontal)
}
}
Using SF Symbol RTL variants automatically
Many SF Symbols ship with RTL-aware variants. Instead of a manual ternary, append .rtl to the symbol name where available, or use the Image(systemName:) + .imageFlippedForRightToLeftLayoutDirection(true) modifier. SwiftUI will automatically show the flipped variant when the environment direction is .rightToLeft. Open the SF Symbols 6 app, select a symbol, and check the "Localization" tab to see if a directional variant exists before writing branching code.
// Preferred: let SwiftUI flip automatically
Image(systemName: "arrow.right")
.imageFlippedForRightToLeftLayoutDirection(true)
// In RTL environments this renders as "arrow.left" without any extra code.
Common pitfalls
-
⚠️ Using
.frame(maxWidth: .infinity, alignment: .left)— hardcoded directions break RTL. Always use.leadingand.trailinginstead of.leftand.rightin every alignment, frame, and padding context. SwiftUI maps semantic directions to physical sides automatically;.leftis always the physical left regardless of locale. -
⚠️ Assuming
GeometryReadercoordinates mirror automatically — they don't. Frames and offsets fromGeometryReaderare always in the physical coordinate space (top-left origin). Any custom drawing using absolute x-coordinates (progress bars, custom sliders, swipe-to-reveal) must manually invert or anchor based onlayoutDirection. -
⚠️ Forgetting
accessibilityLabelon combined rows. VoiceOver reads children in visual order, which reverses in RTL. Use.accessibilityElement(children: .combine)with an explicit.accessibilityLabelto ensure a consistent reading order regardless of layout direction. -
⚠️ Testing only in the simulator without an RTL scheme.
The
.environment(\.layoutDirection, .rightToLeft)preview override catches most layout bugs, but device-level RTL (set via Settings → General → Language & Region) also flips system UI chrome like Navigation bars, tab bars, and sheets. Always do a full-device RTL smoke test before shipping.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement RTL support in SwiftUI for iOS 17+. Use environment/layoutDirection. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data. Cover three scenarios: a list row using semantic .leading alignment, a directional icon that branches on layoutDirection, and a GeometryReader-based component that anchors correctly in RTL. Include both LTR and RTL #Preview macros.
In Soarias, paste this into the Build phase prompt for your i18n story — Claude Code will scaffold the component, wire up the environment, and generate dual previews so you can validate Arabic and Hebrew layouts before wiring up real locale data.
Related
FAQ
Does this work on iOS 16?
Yes — LayoutDirection and environment(\.layoutDirection) have been available since SwiftUI's first release. The #Preview macro requires iOS 17+ / Xcode 15+, so replace it with PreviewProvider if you still support iOS 16. All semantic alignment behaviour described here is identical on iOS 16.
Do I need to do anything special for bidirectional text (e.g. mixing Arabic and English in one label)?
SwiftUI's Text view uses NSAttributedString / CoreText under the hood, which handles Unicode bidirectional text automatically. Mixed-direction strings (e.g. an Arabic sentence containing an English brand name) render correctly without extra work. If you need to force a specific base direction for a paragraph, use multilineTextAlignment(.trailing) combined with the layoutDirection environment — never use multilineTextAlignment(.right).
What is the UIKit equivalent?
In UIKit, you read UIView.userInterfaceLayoutDirection or UIApplication.shared.userInterfaceLayoutDirection. Semantic content attributes (semanticContentAttribute = .forceLeftToRight) control per-view mirroring. SwiftUI's environment(\.layoutDirection) maps directly to this concept but is scoped to the view tree rather than being a global override, making it cleaner and easier to test in previews.
Last reviewed: 2026-05-11 by the Soarias team.