Tab Bar / Bottom Navigation
شريط التنقّل السفليTabView / NavigationBar
also calledtab bar، bottom navigation bar، bottom nav، bottom tabs، bottom tab navigation، BottomNavigationView، شريط التبويب السفلي، تاب بار، بوتوم نافيجيشن، التبويبات السفلية، شريط الأقسام، قائمة التنقّل السفلية
A tab bar is a persistent strip at the bottom of a phone screen carrying three to five top-level destinations, exactly one of which is selected at any moment. It moves you between whole sections rather than between panels, and every destination keeps its own navigation stack: drill into a product detail, jump to another tab, come back, and you land on the screen you left, not on the section root. Web tabs (role="tablist") are a different control: they swap one panel inside a single page and own neither a back stack nor a URL of their own, whereas this switches the roots of the app itself. Its web equivalent is a <nav> of links carrying aria-current="page", and putting role="tab" on it is a plain semantic error. A toolbar is different too: it carries actions that operate on the screen currently showing (delete, share, sort), and its buttons fire and release rather than staying selected, while every item here is a place you go to rather than a verb you run. A segmented control is a third case: it picks a filter value for one view ("All / Unread"), lives inside the content, and never changes your destination or follows you across screens. One behavioural rule most hand-rolled versions miss: tapping the already-selected destination pops its stack back to root, and if you are already at the root it scrolls the content to the top.
If you called it…
Live specimen
Interact with the demo. Every part is real and numbered.
أنت عند جذر هذا القسم. كل وجهة تحتفظ بمكدّس تنقّلها وحدها.
Anatomy: every part, named
Hover a row to locate it in the demo above.
Build prompt
How it behaves right-to-left
This section is ours alone.
The destination order flips
mirrorsThe first destination sits at the far right and the last at the far left, and you get that for free as long as the row is laid out on the inline axis: a Compose Row, a SwiftUI HStack, any flex container without row-reverse. Three things break it. Reversing the destinations array by hand, or adding flex-direction: row-reverse under [dir="rtl"], flips a second time and drops the first tab back on the left while keyboard and TalkBack order stay unreversed, so visual order splits from reading order. On Android nothing mirrors at all until android:supportsRtl="true" is on the <application> tag in the manifest, so a bar that looks right in a preview ships unflipped. And I18nManager.forceRTL(true) in React Native only takes effect after the bundle reloads, which is why the flip looks broken when it has simply not started yet. The index never mirrors: selectedIndex = 0 stays the logically first destination no matter where it is painted.
The indicator travels the logical axis
mirrorsThe active-indicator pill, or the underline that slides to the chosen item, must travel right-to-left in Arabic. Any translateX(index * itemWidth) maths is physical and sends the pill away from the destination the user just tapped, because transform has no logical form in CSS at all. On the web, animate inset-inline-start as a percentage; if you need the compositor, keep the transform but give it a direction sign: translateX(calc(var(--dir) * var(--x))) with --dir: 1 on :root and --dir: -1 under [dir="rtl"]. The quieter trap is the measurement: offsetLeft and getBoundingClientRect().left are physical, so a pill positioned from a measured left value is only correct if you also place it with a physical left. Mix a measured left with inset-inline-start and it lands exactly mirrored. In Compose use Modifier.offset, which negates the x axis under LayoutDirection.Rtl, and never Modifier.absoluteOffset, which ignores direction by design.
The badge: its corner and its number
mirrorsThe badge rides the logical end of the icon: top-right in English, top-left in Arabic. Ready-made components place it for you (.badge in SwiftUI, BadgedBox in Compose); a hand-hung overlay does not, because .offset(x: 10) is a physical shift that never reads layoutDirection. Use a ZStack with alignment: .topTrailing, or read \.layoutDirection from the environment and negate the sign; on the web, swap right: -6px for inset-inline-end: -6px. Then there is the number itself, and this one catches everybody: digits do not mirror, but the overflow cap does. In a counter like 99+, the plus ends the string with no digit after it to anchor it, so the bidi algorithm treats it as a neutral, hands it to the paragraph direction, and paints it to the right of the digits instead of the left. An Arabic user reads "plus ninety-nine". Isolate the counter with <bdi>, unicode-bidi: isolate, or direction: ltr on the badge element, and pick one numeral system for the whole bar, Intl.NumberFormat("ar-EG") for Arabic-Indic, "ar-EG-u-nu-latn" for Latin , never both in one bar.
Object icons never mirror
never mirrorsA house, a person, a shopping cart, a bell are objects with a fixed real-world shape; flipping them looks broken, not translated. Only directional glyphs mirror: the back arrow, the send arrow, the "next" chevron. So never write a blanket [dir="rtl"] svg { transform: scaleX(-1) }. Scope the flip to a class. The platforms hand you the switch: in Compose reach for Icons.AutoMirrored.Filled.ArrowBack instead of the legacy icon, on Android set android:autoMirrored="true" on the vector drawable (it does nothing on a <shape>), and in SF Symbols pick chevron.backward, which mirrors automatically, over chevron.left, which never does. Your own hand-drawn icons carry no such metadata, so declare it yourself: .flipsForRightToLeftLayoutDirection(true) in SwiftUI, or the direction setting on the asset in the catalog.
The safe area is the exception to the logical rule
never mirrorsenv(safe-area-inset-left) and env(safe-area-inset-right) are the one place in this component where you keep writing left and right in spite of everything above. They have no logical counterpart, and their values track how the device is rotated rather than what language the interface is in: in landscape the cutout is on whichever edge the phone was turned toward, which has nothing to do with the edge the text starts from. Swapping the pair under [dir="rtl"], which is what a blanket "turn every left into right" pass does , pads the empty edge and leaves the touch targets genuinely under the cutout. Everything else about anchoring the bar is direction-neutral, because it lives on the block axis: neither inset-block-end: 0 nor padding-block-end: env(safe-area-inset-bottom) needs a [dir="rtl"] rule. iOS draws the same line: UIView.safeAreaInsets in UIKit is a UIEdgeInsets with physical .left and .right, while SwiftUI hands you an EdgeInsets with .leading and .trailing that resolve against layoutDirection, and mixing the two is the shortest path to a bar padded on the empty side.
In code
Each row is one framework's word for the same thing. Take the row your project speaks.
See also
Updated · 2026-08-19