Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/ui/bottom-accessory.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
title: Bottom Accessory
description: A cross-platform tab bar accessory component with native iOS 26 support
---

# Bottom Accessory

The `Tabs.BottomAccessory` component allows you to render content above the tab bar, similar to Apple Music's mini-player. On iOS 26+, it uses the native tab bar accessory API for seamless integration. On Android and web, it provides a user-space implementation that positions content directly above the tab bar.

## Installation

The Bottom Accessory is part of the native-tabs wrapper. No additional installation is required.

## Usage

```tsx
import { Tabs } from "@/components/layout/native-tabs";

export default function Layout() {
return (
<Tabs>
<Tabs.Trigger name="home">
<Tabs.Trigger.Label>Home</Tabs.Trigger.Label>
<Tabs.Trigger.Icon sf="house.fill" />
</Tabs.Trigger>

<Tabs.BottomAccessory>
<NowPlayingBar />
</Tabs.BottomAccessory>
</Tabs>
);
}
```

## Placement Hook

Use the `usePlacement()` hook to adapt your accessory content based on how it's rendered:

```tsx
import { Tabs } from "@/components/layout/native-tabs";

function MyAccessoryContent() {
const placement = Tabs.BottomAccessory.usePlacement();

// placement is 'inline' on iOS 26+ when rendered inline with tab bar
// placement is 'regular' on Android, web, and older iOS

return (
<View style={placement === 'inline' ? styles.compact : styles.regular}>
{/* Your content */}
</View>
);
}
```

## API Reference

### Tabs.BottomAccessory

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | The content to render in the accessory area |
| `className` | `string` | (Web only) Additional CSS classes |

### usePlacement

Returns the current placement mode of the accessory:

| Value | Description |
|-------|-------------|
| `'regular'` | Standard positioning (Android, web, older iOS) |
| `'inline'` | Inline with tab bar (iOS 26+ native mode) |

## Platform Differences

| Platform | Behavior |
|----------|----------|
| iOS 26+ | Uses native `NativeTabs.BottomAccessory` API for seamless integration |
| iOS (older) | User-space implementation positioned above tab bar |
| Android | User-space implementation positioned above tab bar |
| Web | Fixed position at bottom of viewport |

## Examples

### Now Playing Bar

A common use case is a "Now Playing" bar for music apps:

```tsx
function NowPlayingBar() {
const placement = Tabs.BottomAccessory.usePlacement();

return (
<View className="w-full max-w-lg rounded-xl bg-sf-fill p-3 flex-row items-center gap-3">
<Image source={{ uri: albumArt }} className="w-12 h-12 rounded-lg" />
<View className="flex-1">
<Text className="text-sf-text font-medium">Song Title</Text>
<Text className="text-sf-text-2 text-sm">Artist Name</Text>
</View>
<Pressable>
<SFIcon name="play.fill" className="text-sf-text text-xl" />
</Pressable>
</View>
);
}
```

### Search Bar

Another use case is a contextual search or filter bar:

```tsx
function SearchAccessory() {
return (
<View className="w-full max-w-lg">
<TextInput
placeholder="Search..."
className="bg-sf-fill rounded-full px-4 py-2 text-sf-text"
/>
</View>
);
}
```
46 changes: 28 additions & 18 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Suspense, useEffect } from "react";
import { Toaster } from "@/utils/toast";
import { GestureHandlerRootView } from "@/utils/native-gesture-provider";
import { SourceCodePro_400Regular } from "@expo-google-fonts/source-code-pro";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { Tabs } from "@/components/layout/native-tabs";
import { useCSSVariable } from "@/tw";
SplashScreen.preventAutoHideAsync();

Expand All @@ -32,23 +32,33 @@ export default function Layout() {
<AsyncFont src={SourceCodePro_400Regular} fontFamily="Source Code Pro" />
<ThemeProvider>
<GestureHandlerRootView style={{ flex: 1, display: "contents" }}>

<NativeTabs tintColor={label}>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
sf={{
default: "house",
selected: "house.fill",
}}
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(info)" role="search">
<NativeTabs.Trigger.Label>Info</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="cursorarrow.rays" />
</NativeTabs.Trigger>
</NativeTabs>

{/*
Use the Tabs wrapper for cross-platform BottomAccessory support.
On iOS 26+, BottomAccessory uses native tab bar accessories.
On Android/web, it renders a user-space implementation.
*/}
<Tabs tintColor={label}>
<Tabs.Trigger name="(index)">
<Tabs.Trigger.Label>Home</Tabs.Trigger.Label>
<Tabs.Trigger.Icon
sf={{
default: "house",
selected: "house.fill",
}}
/>
</Tabs.Trigger>
<Tabs.Trigger name="(info)" role="search">
<Tabs.Trigger.Label>Info</Tabs.Trigger.Label>
<Tabs.Trigger.Icon sf="cursorarrow.rays" />
</Tabs.Trigger>

{/* Example BottomAccessory - uncomment to see it in action
<Tabs.BottomAccessory>
<NowPlayingBar />
</Tabs.BottomAccessory>
*/}
</Tabs>

<Toaster />
</GestureHandlerRootView>
</ThemeProvider>
Expand Down
135 changes: 135 additions & 0 deletions src/components/layout/native-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client";

import * as React from "react";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { View, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

/* ----------------------------------------------------------------------------------
* Context for BottomAccessory
* ----------------------------------------------------------------------------------*/

type BottomAccessoryPlacement = "regular" | "inline";

interface BottomAccessoryContextValue {
placement: BottomAccessoryPlacement;
}

const BottomAccessoryContext = React.createContext<BottomAccessoryContextValue>(
{
placement: "regular",
}
);

/**
* Hook to get the current placement of the BottomAccessory.
* Returns 'inline' on iOS 26+ when the accessory is rendered inline with the tab bar,
* otherwise returns 'regular'.
*/
function usePlacement(): BottomAccessoryPlacement {
const context = React.useContext(BottomAccessoryContext);
return context.placement;
}

/* ----------------------------------------------------------------------------------
* BottomAccessory Component
* ----------------------------------------------------------------------------------*/

interface BottomAccessoryProps {
children?: React.ReactNode;
}

/**
* BottomAccessory renders content above the tab bar (like Apple Music's mini-player).
*
* On iOS 26+, this uses the native tab bar accessory API.
* On Android and older iOS versions, this renders a user-space implementation
* that positions content directly above the tab bar.
*/
function BottomAccessory({ children }: BottomAccessoryProps) {
const insets = useSafeAreaInsets();

// Check if native BottomAccessory is available (iOS 26+)
const NativeBottomAccessory = (NativeTabs as any).BottomAccessory;

if (process.env.EXPO_OS === "ios" && NativeBottomAccessory) {
// Use native implementation on iOS 26+
return <NativeBottomAccessory>{children}</NativeBottomAccessory>;
}

// User-space implementation for Android and older iOS
return (
<View
style={[
styles.accessoryContainer,
{
// Position above the tab bar (approximately 49pt on iOS, 56dp on Android)
bottom: (process.env.EXPO_OS === "ios" ? 49 : 56) + insets.bottom,
},
]}
>
<BottomAccessoryContext.Provider value={{ placement: "regular" }}>
{children}
</BottomAccessoryContext.Provider>
</View>
);
}

// Attach the usePlacement hook to the component
BottomAccessory.usePlacement = usePlacement;

const styles = StyleSheet.create({
accessoryContainer: {
position: "absolute",
left: 0,
right: 0,
zIndex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 16,
},
});

/* ----------------------------------------------------------------------------------
* Tabs Wrapper
* ----------------------------------------------------------------------------------*/

type TabsProps = React.ComponentProps<typeof NativeTabs> & {
children?: React.ReactNode;
};

/**
* Tabs wrapper around NativeTabs that adds support for BottomAccessory
* with fallback implementations for Android and web.
*/
function Tabs({ children, ...props }: TabsProps) {
// Separate BottomAccessory children from other children
const { accessories, otherChildren } = React.useMemo(() => {
const accessories: React.ReactNode[] = [];
const otherChildren: React.ReactNode[] = [];

React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === BottomAccessory) {
accessories.push(child);
} else {
otherChildren.push(child);
}
});

return { accessories, otherChildren };
}, [children]);

return (
<NativeTabs {...props}>
{otherChildren}
{accessories}
</NativeTabs>
);
}

// Re-export all NativeTabs sub-components
Tabs.Trigger = NativeTabs.Trigger;
Tabs.BottomAccessory = BottomAccessory;

export { Tabs, usePlacement };
export type { BottomAccessoryProps, BottomAccessoryPlacement };
Loading