Tabs

<native-tabs-outlet> renders a real UITabBarController on iOS and a bottom navigation bar on Android. Tabs are declared as content, the same way a header is declared in a page's own template, because a badge is a signal that changes, a title may be translated, and a tab might not exist until a feature flag says so - none of which a static route config can express:

Import
import { NativeTabsOutlet, NativeTab, TabSafeAreaView } from '@ng-native/router';
Template
<native-tabs-outlet><native-tab><tab-safe-area-view> once they are in the component's imports
native-tabs-outletcomponent@ng-native/router
import { NativeTabsOutlet } from '@ng-native/router';and add NativeTabsOutlet to the component's imports

Inputs

barHidden
unknown

Hide the bar without unmounting the tabs, for a screen that wants the whole window.

tintColor
string | number

The colour of the selected item. iOS.

backgroundColor
string | number

Behind the tabs, where a screen does not paint.

colorScheme
'inherit' | 'light' | 'dark'

Force the bar's light or dark appearance, whatever the app's is.

minimizeBehavior
'automatic' | 'never' | 'onScrollDown' | 'onScrollUp'

iOS 26: let the bar shrink out of the way as content scrolls.

controllerMode
'automatic' | 'tabBar' | 'tabSidebar'

iOS: tabSidebar is the iPad layout, with the tabs down the side.

respectsKeyboard
unknown

Android: keep the bar above the keyboard rather than behind it.

Methods

detach(): ComponentRef<unknown>

Hand the live ComponentRef back to the router. The tab's screen stays where it is with the component still inside it, so the tab keeps running while another one is in front.

native-tabcomponent@ng-native/router
import { NativeTab } from '@ng-native/router';and add NativeTab to the component's imports

Inputs

pathrequired
string

The child route this tab selects, as a path segment: library for /tabs/library . Native identifies the tab by it too, so it must be unique in the bar.

title
string

The label under the icon. Absent gives an icon-only item.

sfSymbol
string

iOS: an SF Symbol name, e.g. books.vertical.fill . Shorthand for [icon] .

drawable
string

Android: a drawable resource name. Shorthand for [icon] .

icon
TabIcon

Anything a symbol name cannot say: an image, a template image, an asset-catalogue name.

selectedIcon
TabIcon

Shown while this tab is the selected one. Absent leaves the platform to tint icon .

systemItem
TabSystemItem

One of Apple's standard items, which supplies its own icon and title.

badge
string

The badge on the item: a count, or a single character for a dot.

accessibilityLabel
string

Announced instead of the title.

standardAppearance
TabAppearance

How the item looks. Colours are processed here, because native never sees the object's keys.

scrollEdgeAppearance
TabAppearance

The same, for when the scroll view behind the bar is at its edge.

@Component({
  selector: 'x-tabs',
  imports: [NativeHeader, NativeTab, NativeTabsOutlet],
  template: `
    <native-header [hidden]="true" />
    <native-tabs-outlet>
      <native-tab path="library" title="Library" sfSymbol="books.vertical.fill" />
      <native-tab path="inbox" title="Inbox" sfSymbol="tray" [badge]="unread()" />
    </native-tabs-outlet>
  `,
})
export class TabsPage {
  protected readonly unread = signal('3');
}

path names the child route the tab selects - the route config underneath needs one child route per <native-tab>, matched by that same path. sfSymbol (iOS) and drawable (Android) are shorthand for the fuller icon/selectedIcon inputs, which also take a require()d image drawn either as-authored or as a tinted template mask; icon and selectedIcon must be the same kind of image, since native carries one icon type for both states.

A bar item is not a view - UITabBarItem and Android's bottom-navigation item are model objects, a title and an image and a badge string, with no way to put a view in their place. That is why <native-tab> takes no content while <native-header-item> does, and why customisation beyond the basics lives in the standardAppearance/scrollEdgeAppearance inputs rather than in markup.

Defaults for every tab bar

withTabDefaults is withHeaderDefaults for tab bars: the outlet's tintColor, backgroundColor and colorScheme, and each tab's standardAppearance and scrollEdgeAppearance, said once beside the routes. As with headers it takes an object or a function of the colour scheme, and anything an outlet or a tab binds itself wins.

import { provideNativeRouter, withTabDefaults } from '@ng-native/router';
import { routes } from './routes.ts';

export const providers = [
  provideNativeRouter(
    routes,
    withTabDefaults((scheme) => ({
      tintColor: '#3b6ef5',
      backgroundColor: scheme === 'dark' ? '#101014' : '#f4f4f7',
    })),
  ),
];

A tab that binds its own standardAppearance replaces the default one whole rather than merging into it, the same as any other input.

Native holds the current selection, not your app - a tap is round-tripped through the router as a real navigation, so the URL and the bar never disagree, and each tab keeps its own screen (and its own stack, if it has one) mounted while another tab is in front. Returning to a tab returns to wherever it was left, because the outlet remembers each tab's own url.

Content above the tab bar

A tab's screen runs to the bottom of the window, and the bar is drawn over it: on iOS 26 it floats over the content, on earlier iOS it is translucent and overlays it, and on Android the bottom navigation bar sits on top of it. Anything pinned to the bottom of a tab - a panel of controls, a mini player - has to be lifted clear of the bar, by an amount that differs between devices, versions and platforms.

<safe-area-view> cannot do it. Its insets come from the app's <safe-area-provider>, which is at the root, above the tab bar, so inside a tab its bottom inset is only the home indicator. <tab-safe-area-view> asks the tab screen instead: on iOS that is the screen's own safe area, which UIKit extends by the bar, and on Android the larger of the bar's height and the system bars. It follows the bar as it changes size or is hidden.

tab-safe-area-viewcomponent@ng-native/router
import { TabSafeAreaView } from '@ng-native/router';and add TabSafeAreaView to the component's imports

Inputs

edges
readonly TabSafeAreaEdge[]

Which edges to inset: usually ['bottom'] . Absent insets all four.

The inset is applied as margin, so whatever is behind it shows through. When a panel's background has to reach the bottom of the screen, so that the bar floats over the panel rather than over whatever the panel sits on, give the background to a parent and put the <tab-safe-area-view> inside it. The parent grows by the margin:

<view class="absolute bottom-0 left-0 right-0 rounded-t-3xl bg-white px-5 pt-4">
  <tab-safe-area-view [edges]="['bottom']" class="pb-4">
    <!-- controls, which now end 16 points above the bar -->
  </tab-safe-area-view>
</view>

Content that should float with nothing behind it, such as a mini player, needs no parent:

@Component({
  selector: 'x-library-stack',
  imports: [MiniPlayer, NativeStackOutlet],
  template: `
    <native-stack-outlet />
    <x-mini-player class="absolute bottom-0 left-0 right-0" />
  `,
})
export class LibraryStack {}

with a <tab-safe-area-view [edges]="['bottom']"> inside the mini player's own template. It has to be inside a tab to know about the bar, so something every tab shows is placed in each tab - in a tab's stack component, beside its <native-stack-outlet>, it stays put as pages are pushed. Placed beside the <native-tabs-outlet> instead, it is outside every tab screen, and on iOS it insets by nothing at all.

A scroll view that fills the tab can usually do without: on iOS, with contentInsetAdjustmentBehavior="automatic", it insets its own content by the safe area, the bar included. Android has no equivalent, so there the last rows scroll under the bar unless the content is padded.

Going back

Android's back button pops the stack in the tab in front, never one in a tab behind it. That is a pop, not one entry back through history: after a trip to another tab and back, the entry behind the top screen is that other tab, and going back to it would switch tab instead. A screen pushed over the whole bar, on the app's own stack, is popped before anything inside the bar.

Once the stack in front is at its root, or the tab has no stack, back goes to the first tab, on whatever screen it was left on. On the first tab the button leaves the app, which Android backgrounds rather than closes. This is how a bottom navigation bar behaves on Android; the order the tabs were visited in is not retraced, because after a few switches nobody could predict where that lands.

NativeNavigation.back() does the same, except that it cannot leave the app: on the first tab's root it does nothing.

A tab screen with no stack of its own has no header above it, so nothing clears the status bar for it. iOS hides that, because a scroll view insets its own content by the safe area; Android does not, and draws edge to edge, so the page's first line lands under the clock. Wrap such a page in <safe-area-view [edges]="['top']"> - on iOS the scroll view inside then no longer overlaps the unsafe area and adds nothing of its own, so the inset is not doubled.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.