name: rtl description: Audit and maintain RTL (right-to-left) layout compatibility in stream-chat-react-native. Use when changing styles, positioning, flex layouts, swipe gestures, animated transforms, icons, text alignment, or anything that has a horizontal/directional axis.
RTL Compatibility Audit (stream-chat-react-native)
Use this skill whenever code changes can affect users in RTL locales (Hebrew he ships today; Arabic/Persian/Urdu integrators are common). React Native flips some layout properties automatically via I18nManager.isRTL, but absolute positioning, hardcoded margins/paddings, transforms, swipe gestures, and SVG icons must be handled by hand.
When the user asks for an "RTL audit" or "RTL review," walk the Audit checklist against the diff (or the named files), then return findings grouped by severity. When writing new code, apply the Patterns to follow rather than just the anti-patterns at the end.
Non-negotiable rules
- Read direction at runtime. Use
I18nManager.isRTLfromreact-native. Never assume LTR. Never assume a value at module load time only —I18nManager.isRTLis a static snapshot per JS bundle (RN reloads the bundle on direction change), so module-scope reads are fine, but state that depends on it must not be cached across user-driven direction toggles within a single session unless the bundle is reloaded. - Logical properties beat physical ones. Prefer
start/endvariants (paddingStart,marginEnd,borderStartWidth,insetStart) overleft/rightfor spacing and borders. RN auto-flipsstart/endbased onI18nManager.isRTL. The exception is absolute positioning — RN does NOT auto-flipleft/righton absolutely positioned elements; those need an explicitI18nManager.isRTLconditional. - flexDirection: 'row' auto-flips. Default
flexDirection: 'row'reverses in RTL. Do NOT counter this by manually setting'row-reverse'for "alignment fixes" — that double-flips and breaks RTL. Only use'row-reverse'when the visual order must be opposite of reading order in both directions. - Text alignment defaults to writing direction. For
Text, defaulttextAlignis already direction-aware. SettextAlign: 'left'/'right'ONLY when you need a fixed visual side; otherwise omit it or usetextAlign: 'auto'. When you need "align to start of reading direction" explicitly, writetextAlign: I18nManager.isRTL ? 'right' : 'left'. writingDirectionon Text that mixes scripts. When user-generated text could contain RTL characters (messages, channel names, member names, poll options, inputs), setwritingDirection: I18nManager.isRTL ? 'rtl' : 'ltr'(iOS) so bidi resolution matches the app direction. Or wrap withWritingDirectionAwareTextfrompackage/src/components/RTLComponents/.- Mirror directional icons; don't mirror neutral ones. Arrows, chevrons, reply, send, thread, search-magnifier, message-bubble must flip in RTL. Symmetric icons (checkmark, bell, settings gear, like-heart, emoji face) must NOT flip. Use SVG
transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 W 0)' : undefined}whereWis the SVG width. - Swipe gestures need a direction multiplier. Any gesture that moves content along the X-axis (swipe-to-reply, swipe-to-delete, paging) must multiply
translationXbyI18nManager.isRTL ? -1 : 1. Otherwise swipe-from-right-to-left does the wrong thing in RTL. - Backward-compatible. RTL fixes should not change LTR behavior. When in doubt, the conditional form
I18nManager.isRTL ? rtl : ltris safer than swapping a default.
Where to put what
- Foundation primitives & helpers →
package/src/utils/(e.g.,rtlMirrorSwitchStyle.ts) andpackage/src/components/RTLComponents/(e.g.,WritingDirectionAwareText.tsx). - Component-level RTL handling → in the component itself. Read
I18nManager.isRTLat the top of the render or inuseStyles(). - Icons →
package/src/icons/. Existing pattern: SVGtransform="matrix(-1 0 0 1 <width> 0)"gated onI18nManager.isRTL. - Theme → there are no RTL-specific theme tokens. Don't add new directional values to
theme.ts(paddingLeft,marginRight); usestart/endkeys instead, or compute in the consumer. - Locale files →
package/src/i18n/he.jsonis the only shipped RTL locale. Test RTL by settingI18nManager.forceRTL(true)+ reload, or by switching the device to Hebrew. - Platform divergence (iOS vs Android) → some platforms (iOS) require a transform mirror for native components like
Switch. UseuseRtlMirrorSwitchStyle()rather than inlining.
Patterns to follow
1) Reading direction
import { I18nManager } from 'react-native';
const isRTL = I18nManager.isRTL;
Keep this at component top, or compute style objects with it inside useStyles(). Don't gate behavior on Platform.OS and assume direction — RTL works on both iOS and Android.
2) Spacing: prefer logical properties
// GOOD — auto-flips
{ marginStart: 8, paddingEnd: 12, borderStartWidth: 1 }
// AVOID for spacing — does not flip
{ marginLeft: 8, paddingRight: 12, borderLeftWidth: 1 }
When migrating, the rename is direct: Left → Start, Right → End. Test once in LTR + once in RTL.
3) Absolute positioning: conditional
left / right on absolutely positioned elements do not auto-flip. Either use insetStart/insetEnd (RN 0.71+) or branch:
const positionStyle = I18nManager.isRTL ? { left: 0 } : { right: 0 };
Common offenders: scroll-to-bottom button, online-presence dot on avatars, badge counts, overlay anchors, swipe-action content underneath a row.
4) Message-bubble alignment
Own messages render on the end side, others on the start. The alignment value ('left' | 'right') refers to physical sides for layout decisions, but for overlays/menus anchored to the bubble, flip it through:
const overlayItemAlignment = I18nManager.isRTL
? alignment === 'right' ? 'left' : 'right'
: alignment;
(see package/src/components/Message/Message.tsx:420-431)
5) Swipe-to-reply / pan gestures
const swipeDirectionMultiplier = I18nManager.isRTL ? -1 : 1;
.onChange(({ translationX }) => {
const swipeDistance = translationX * swipeDirectionMultiplier;
if (swipeDistance > 0) translateX.value = swipeDistance;
})
(see package/src/components/Message/MessageItemView/MessageBubble.tsx:33-86 and package/src/components/UIComponents/SwipableWrapper.tsx:67)
For SwipableWrapper, if a side prop is not provided, default it from direction:
const resolvedSide = side ?? (I18nManager.isRTL ? 'left' : 'right');
const translationDirection = resolvedSide === 'right' ? -1 : 1;
6) Directional SVG icons
For arrow/chevron/reply/send/thread/search/message-bubble icons:
<Svg ...>
<Path
transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 20 0)' : undefined}
d="..."
/>
</Svg>
The translate component (20 here) must equal the SVG's width so the mirror lands inside the viewBox. Special case for arrow-left.tsx: it rotates instead of matrix-mirrors — keep that style consistent with its sibling.
When adding a new icon, ask: does this icon point in a direction (e.g., →) or carry directional meaning (e.g., "next", "reply")? If yes, mirror. If no (checkmark, bell, gear, emoji), don't.
7) Text content with mixed scripts
<Text style={{ writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' }}>{userInput}</Text>
Or:
import { WritingDirectionAwareText } from '../../RTLComponents/WritingDirectionAwareText';
<WritingDirectionAwareText>{userInput}</WritingDirectionAwareText>
Apply to: message body, channel name, member names, poll options, search inputs, autocomplete tokens. Skip for purely numeric/symbolic content (timestamps, unread counts).
8) Native Switch mirroring on iOS
import { useRtlMirrorSwitchStyle } from '../../utils/rtlMirrorSwitchStyle';
const mirror = useRtlMirrorSwitchStyle();
<Switch style={[styles.switch, mirror]} ... />
Returns { transform: [{ scaleX: -1 }] } only when Platform.OS === 'ios' && I18nManager.isRTL. iOS Switch doesn't natively flip; Android does.
9) Inverted FlatList and horizontal scroll
FlatList inverted works correctly in RTL (it flips along the cross axis). Horizontal FlatLists auto-reverse content order in RTL — verify visually for emoji-reaction pickers and attachment-preview strips that the start of the list is at the end of the row in LTR and at the start in RTL.
10) transform: translateX / scaleX
translateX is in absolute pixels — positive X is right on screen regardless of direction. If your animation moves "toward the end" (e.g., sliding off-screen), multiply by isRTL ? -1 : 1. scaleX: -1 is a mirror; only use it intentionally (the iOS Switch helper above, video direction in AnimatedGalleryVideo).
Anti-patterns to avoid
- Hardcoded
marginLeft/paddingRightfor spacing — usemarginStart/paddingEndso RN can flip them. Acceptable only when you genuinely want a fixed visual side (rare). - Absolute
left: Xorright: Xwithout a direction check — these do NOT flip. Add a conditional. flexDirection: 'row-reverse'to "fix" alignment — you've broken RTL. Use'row', which already flips correctly.textAlign: 'left'on user content — pins text to the left even in RTL. Either omit it, use'auto', or conditionalize onisRTL.- Setting
writingDirection: 'ltr'unconditionally on user-generated text — strips bidi resolution for Arabic/Hebrew content. Branch onI18nManager.isRTL. - Mirroring symmetric icons (checkmark, bell, gear, emoji, like-heart) — they look wrong flipped. Mirror only directional icons.
- Forgetting the swipe-direction multiplier on new pan gestures — the gesture activates in the wrong direction in RTL.
- Caching
I18nManager.isRTLat module load and assuming it never changes is fine within a session; relying on it to update mid-session without bundle reload is not — RN reloads onforceRTLchange. - New directional values in
theme.ts(paddingLeft,marginRight, hardcodedright: -12) — push the conditional into the consumer, or usestart/end. - Assuming
I18nManager.forceRTL(true)alone flips the running app — it persists for the next bundle reload. Tests must mockI18nManager.isRTL(see Testing).
Audit checklist
Walk this checklist against any diff that touches layout, positioning, gestures, transforms, icons, or text. Group findings by severity:
- HIGH: visible breakage in RTL (text on wrong side, swipe wrong direction, icon points wrong way, overlay anchored to wrong edge).
- MEDIUM: misaligned spacing (margins/paddings on wrong side) — readable but off.
- LOW: stylistic (could use logical property but current code is technically correct).
Layout & positioning
- No new
marginLeft/marginRight/paddingLeft/paddingRightfor spacing — usemarginStart/marginEnd/paddingStart/paddingEnd. - No new
borderLeftWidth/borderRightWidth/borderLeftColor/borderRightColoretc. — useborderStartWidth/borderEndWidth/borderStartColor/borderEndColor. - Any new absolute
left:/right:positioning is wrapped inI18nManager.isRTL ? ... : ...(or usesinsetStart/insetEnd). - No new
flexDirection: 'row-reverse'introduced as an "RTL fix" (it isn't). - Negative offsets (e.g.,
right: -12for an overlapping badge) are conditional on direction.
Text
- No new
textAlign: 'left'or'right'on user-generated content; if needed, conditional onI18nManager.isRTL. -
Textcomponents rendering user-generated/mixed-script content setwritingDirection(or useWritingDirectionAwareText). - Number-only / time / count strings are NOT given
writingDirection(they're neutral).
Icons
- New directional SVG icons (arrows, chevrons, send, reply, thread, message-bubble, search) have
transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 <width> 0)' : undefined}on the Path. - The matrix translate value matches the SVG width.
- Symmetric/neutral icons (checkmark, bell, gear, like-heart, emoji) are NOT mirrored.
Gestures & animations
- New
Gesture.Pan()handlers that act ontranslationXmultiply byI18nManager.isRTL ? -1 : 1. - Reanimated
useAnimatedStylereturningtranslateXaccounts for direction when "toward the end" is meant. -
withSpring/withTimingtargets toward an edge are flipped in RTL. - New swipe-action wrappers default
sidefromI18nManager.isRTLif not provided.
Lists & scroll
- Horizontal
FlatList/ScrollViewcontent visually starts at the end of the row in LTR (start of row in RTL) — verify or accept default RN flip. -
invertedFlatList(e.g.,MessageList) still renders newest at the bottom in both directions.
Native components
- iOS
SwitchusesuseRtlMirrorSwitchStyle(). -
TextInputtextAlignis conditional or omitted (RN handles default).
i18n
- No hardcoded English/LTR-only punctuation assumptions in concatenated strings — prefer interpolation via
t()with placeholders. - If adding strings, verify
he.jsonhas the same key (yarn build-translationskeeps locales in sync).
Testing requirements per change
Minimum:
- For visible RTL changes, manually verify in the sample app by toggling Hebrew (
he) or by callingI18nManager.forceRTL(true)inindex.jsand reloading. - For unit tests, mock direction:
Restore between tests (import { I18nManager } from 'react-native'; jest.spyOn(I18nManager, 'isRTL', 'get').mockReturnValue(true);afterEach(() => jest.restoreAllMocks())).
Recommended for non-trivial changes:
- Render the component twice (LTR + RTL) and snapshot the resulting style props for the directional surfaces.
- For gesture handlers, drive a fake
Gesture.Panwith both positive and negativetranslationXunder each direction and assert which one triggers the action.
Execution checklist (copy this when making an RTL change)
- Identified directional axes in the change (spacing, absolute pos, gestures, icons, text)
- Spacing uses
start/endlogical properties - Absolute positions are conditional on
I18nManager.isRTL(or useinsetStart/insetEnd) - No
flexDirection: 'row-reverse'added as a flip fix - New gestures multiply
translationXby direction multiplier - New directional SVG icons carry the matrix-mirror transform; symmetric ones do not
- Text components with user-generated content set
writingDirection - Tested with
I18nManager.isRTLmockedtrueANDfalse - Visually verified in Hebrew locale (or via
forceRTL(true)+ reload) for non-trivial UI -
yarn lintpasses -
yarn test:typecheckpasses (run after any code change)
Reference files (in this repo)
package/src/components/Message/Message.tsx:420-431— alignment + overlay-alignment flip pattern.package/src/components/Message/MessageItemView/MessageBubble.tsx:33-86— swipe-direction multiplier on pan gesture.package/src/components/UIComponents/SwipableWrapper.tsx:67,128— direction-aware defaultside+ translation sign.package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx:169—rightvsleftoverlay anchor flip.package/src/components/Message/MessageItemView/MessageReplies.tsx:58— physical-alignment flip helper.package/src/components/ui/Input/Input.tsx:230andpackage/src/components/AutoCompleteInput/AutoCompleteInput.tsx:207— direction-awaretextAlignfor inputs.package/src/components/RTLComponents/WritingDirectionAwareText.tsx— drop-inTextwithwritingDirection.package/src/utils/rtlMirrorSwitchStyle.ts— iOSSwitchmirror hook.package/src/icons/chevron-right.tsx,chevron-left.tsx,reply.tsx,send.tsx,thread.tsx,search.tsx,message-bubble.tsx— canonical SVG mirror pattern.package/src/i18n/he.json— only shipped RTL locale; reference for translation parity.
Known hazard hotspots
Files most prone to RTL bugs when touched (audit these closely):
package/src/components/MessageList/ScrollToBottomButton.tsx— badge absolute positioning (right: 0).package/src/components/ui/Avatar/AvatarGroup.tsx,AvatarStack.tsx,UserAvatar.tsx— overlapping/clustered avatar offsets and presence dot.package/src/components/MessageInput/MessageComposer.tsx— overlay anchors, icon-end positioning.package/src/components/MessageList/MessageList.tsx,MessageFlashList.tsx— sticky headers and overlay anchors.package/src/components/MessageMenu/MessageReactionPicker.tsx,MessageActionListItem.tsx— horizontal reaction strip + icon padding.package/src/components/Reply/Reply.tsx— quoted-message row layout.package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx— leading-icon row.package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx,ImageGallery.tsx—scaleX/translateXanimations.package/src/components/Attachment/Audio/AudioAttachment.tsx,WaveProgressBar.tsx,ProgressControl.tsx— progress-bar fill direction.package/src/contexts/themeContext/utils/theme.ts— any new directional defaults belong in consumers, not here.