Documentation Index
Fetch the complete documentation index at: https://mintlify.com/felipe-software/react-native-jelly-tabs/llms.txt
Use this file to discover all available pages before exploring further.
JellyTabBarHeadless is the router-agnostic version of the Jelly tab bar. It gives you the full jelly animation engine — pill snapping, drag gestures, touch feedback, masked active icons — with no assumptions about how your app manages navigation.
When to Use JellyTabBarHeadless
- Custom routing solutions — your app uses its own state-based navigation rather than Expo Router or React Navigation.
- Fully controlled tab state — you want to drive the selection from an external source (Redux, Zustand, URL params) and potentially reject changes.
- Demo or onboarding screens — you want an animated tab bar as a UI element without wiring it to a navigator.
- Component libraries — you’re building abstractions on top and need direct control over items and selection.
If you’re using Expo Router or React Navigation, use JellyTabBar instead.
It wraps JellyTabBarHeadless and connects it to the router automatically.
Required Prop: items
The only required prop is items, an array of TabsItem objects that describe each tab:
interface TabsItem {
key: string; // unique stable identifier
label: string; // displayed below the icon; used as accessibility label fallback
labelStyle?: StyleProp<TextStyle>;
activeIcon: TabsIcon; // component rendered through the pill mask (selected state)
inactiveIcon: TabsIcon; // component rendered in the track (unselected state)
badge?: number | string; // optional badge value
badgeStyle?: StyleProp<TextStyle>;
accessibilityLabel?: string; // overrides label for VoiceOver / TalkBack
testID?: string; // sets testID on the accessible view
}
TabsIcon is a React component type:
type TabsIcon = ComponentType<TabsIconProps>;
interface TabsIconProps {
color: string; // resolved active or inactive content color
colors: Readonly<TabBarColors>; // full color palette
size: number; // resolved icon size (default 28)
opacity: number; // resolved layer opacity
}
How activeIcon and inactiveIcon Work
The bar renders two complete copies of every tab simultaneously:
inactiveIcon — drawn in the track at all times, visible through the track background.
activeIcon — drawn in an identically laid-out layer, but revealed only through the animated pill mask.
Because the two icon layers are separate React components, the active and inactive states can use completely different glyphs, colors, weights, or structure — not just a color change. The pill mask clips activeIcon to the animated pill shape as it moves and squishes, creating the jelly reveal effect.
Controlled Mode
Provide selectedIndex to drive the selection externally. The pill animates to the matching tab whenever selectedIndex changes:
const [activeTab, setActiveTab] = useState(0);
<JellyTabBarHeadless
items={items}
selectedIndex={activeTab}
onTabPress={({ index }) => setActiveTab(index)}
/>
Pass null or a negative number to render the bar with no pill selected:
<JellyTabBarHeadless items={items} selectedIndex={null} />
Uncontrolled Mode
Omit selectedIndex and the component manages its own internal selection state, starting at index 0:
<JellyTabBarHeadless
items={items}
onTabChange={({ index, item }) => console.log("changed to", item.label)}
/>
Event Callbacks
onTabPress
Fires after every completed tap or drag, including a tap on the already-selected tab. Return false to reject the change and restore the current selection:
onTabPress={({ index, item }) => {
if (!isTabUnlocked(index)) {
showUpgradePrompt();
return false; // reject — pill snaps back
}
// returning true or void accepts the change
}}
onTabChange
Fires only when the selected tab actually changes to a new index. Rejected presses and taps on the already-selected tab do not emit this event:
onTabChange={({ index, item }) => {
router.navigate(item.key);
}}
onTabLongPress
Fires when a tab is held down. Also triggered by the longpress accessibility action on VoiceOver / TalkBack. Providing this prop also adds the longpress accessibility action to each tab:
onTabLongPress={({ index, item }) => {
showContextMenu(item.key);
}}
Layout Sizing
JellyTabBarHeadless does not add any safe-area insets or automatic height. Wrap it in a container sized to the bar’s expected dimensions:
// default trackHeight is 64; scale with displayScale if used
<View style={{ height: 64, width: "100%" }}>
<JellyTabBarHeadless items={items} selectedIndex={selectedIndex} />
</View>
The exact height is config.layout.trackHeight × displayScale. The default trackHeight is 64. If you pass a custom config, read the resolved height via resolveTabBarConfig:
import { resolveTabBarConfig } from "react-native-jelly-tabs";
const resolvedConfig = resolveTabBarConfig({ layout: { trackHeight: 72 } });
const barHeight = resolvedConfig.layout.trackHeight; // 72
Position the wrapper yourself — typically at the bottom of the screen with padding for the device’s safe-area inset.
Full Working Example
A three-tab headless bar with custom SVG icons and controlled state:
import { useState } from "react";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Path } from "react-native-svg";
import { JellyTabBarHeadless } from "react-native-jelly-tabs";
import type { TabsItem, TabsIconProps } from "react-native-jelly-tabs";
// --- Icon components ---
const HomeIconActive = ({ color, size }: TabsIconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</Svg>
);
const HomeIconInactive = ({ color, size }: TabsIconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={1.5}>
<Path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</Svg>
);
const SearchIconActive = ({ color, size }: TabsIconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</Svg>
);
const SearchIconInactive = ({ color, size }: TabsIconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={1.5}>
<Path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</Svg>
);
const ProfileIconActive = ({ color, size }: TabsIconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</Svg>
);
const ProfileIconInactive = ({ color, size }: TabsIconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={1.5}>
<Path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</Svg>
);
// --- Tab items ---
const TABS: TabsItem[] = [
{
key: "home",
label: "Home",
accessibilityLabel: "Home tab",
activeIcon: HomeIconActive,
inactiveIcon: HomeIconInactive,
},
{
key: "search",
label: "Search",
accessibilityLabel: "Search tab",
activeIcon: SearchIconActive,
inactiveIcon: SearchIconInactive,
},
{
key: "profile",
label: "Profile",
accessibilityLabel: "Profile tab",
activeIcon: ProfileIconActive,
inactiveIcon: ProfileIconInactive,
badge: 1,
},
];
// --- Screen content ---
const SCREEN_LABELS = ["Home Screen", "Search Screen", "Profile Screen"];
// --- Root component ---
export default function App() {
const [selectedIndex, setSelectedIndex] = useState(0);
const insets = useSafeAreaInsets();
return (
<View style={styles.root}>
{/* Screen content */}
<View style={styles.screen}>
<View style={styles.center}>
{/* render the active screen based on selectedIndex */}
</View>
</View>
{/* Tab bar container — sized to trackHeight (64) + safe-area bottom */}
<View
style={[
styles.tabBarWrapper,
{ paddingBottom: insets.bottom + 12, paddingHorizontal: 20 },
]}
>
<View style={styles.tabBarInner}>
<JellyTabBarHeadless
items={TABS}
selectedIndex={selectedIndex}
onTabPress={({ index }) => setSelectedIndex(index)}
onTabLongPress={({ item }) => console.log("long press:", item.label)}
/>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#f5f1e8" },
screen: { flex: 1 },
center: { flex: 1, alignItems: "center", justifyContent: "center" },
tabBarWrapper: {
width: "100%",
paddingTop: 12,
},
tabBarInner: {
height: 64, // matches default config.layout.trackHeight
width: "100%",
maxWidth: 400,
alignSelf: "center",
},
});
Deprecated Alias
JellyTabs is a deprecated alias for JellyTabBarHeadless. It accepts the same props and will continue to work, but you should migrate to the new name:
// ✅ Preferred
import { JellyTabBarHeadless } from "react-native-jelly-tabs";
// ⚠️ Deprecated — still works but will be removed in a future version
import { JellyTabs } from "react-native-jelly-tabs";