UIDex

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…

"the row of icons stuck to the bottom of the app""the four buttons at the bottom that switch sections""the home / search / profile bar""the icon that fills in solid when you pick it""the bottom bar with the little red dot on one icon"

Live specimen

Interact with the demo. Every part is real and numbered.

٩:٤١
الرئيسية

أنت عند جذر هذا القسم. كل وجهة تحتفظ بمكدّس تنقّلها وحدها.

RTLالوجهة الأولى عند اليمين، والمؤشّر ينزلق على المحور المنطقي

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a bottom tab bar for a mobile app with four top-level destinations. Every item is a destination and not an action, never exceed five, and do not smuggle a create button that opens a sheet into it. Give each destination its own independent navigation stack that resumes exactly where the user left it, and make a second tap on the already-selected destination pop that stack to root, then scroll to top if it is already at the root. Each item carries an outline icon when idle and a filled one when selected, a one-word label that is always visible and never truncated, an optional badge capped at 99+, and a touch target of at least 44pt / 48dp covering the whole cell. Do not signal selection with colour alone: change the icon shape and add an active indicator behind it. Pin the bar with inset-block-end: 0 and inset-inline: 0, bleed the background to the device edge with padding-block-end: env(safe-area-inset-bottom) and viewport-fit=cover, and never swap env(safe-area-inset-left/right) when direction changes. Use logical properties throughout (padding-inline, inset-inline-start, margin-inline), and never write left or right. Animate the selection indicator by changing inset-inline-start rather than translateX so it slides the correct way when the page mirrors, position the badge with inset-inline-end, and do not mirror object icons (house, person, cart) in RTL: scope any flip to directional glyphs only. Derive the selected state from the current route rather than a separate variable, and expose it with aria-current="page" on the web equivalent or the native selected flag.

How it behaves right-to-left

This section is ours alone.

The destination order flips

mirrors

The 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

mirrors

The 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

mirrors

The 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 mirrors

A 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 mirrors

env(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.

SwiftUITabView { … .tabItem { Label(…) } }The whole bar from one container; each screen carries .tabItem, selection binds the active destination, and .badge(3) attaches a count.
UIKitUITabBarControllerEach viewController is its own UINavigationController; anything past five gets folded into moreNavigationController.
Jetpack ComposeNavigationBar { NavigationBarItem(…) }The Material 3 name for it (successor to M2 BottomNavigation), with the active indicator tuned via indicatorColor.
Navigation ComposepopUpTo(startDestination) { saveState = true }With launchSingleTop and restoreState, this is the official recipe for a per-destination back stack.
React NavigationcreateBottomTabNavigator()Its options are tabBarIcon and tabBarBadge, and the tabPress event is where pop-to-root is wired.
ARIA<nav aria-label> + aria-current="page"Navigation links inside a nav landmark are the correct web equivalent, never role="tablist".
CSSpadding-block-end: env(safe-area-inset-bottom)Requires viewport-fit=cover in the viewport meta tag, otherwise the value silently resolves to zero.

See also

Updated · 2026-08-19