Icon-only bar whose underline eases to the tapped tab.
35·Bottom TabsNew
Dependencies
react-native-reanimatednativewind
A floating bottom bar with rounded corners and outline icons. Tap a tab and a short underline eases across to sit
under it: quick to start, soft to stop, no bounce.
No labels by default, so it suits apps with four or five well-known sections (home, search, likes, notifications,
profile).
Pre-release
The skiddie CLI isn't published yet. The commands below are the planned ones and won't work until v0.1.
$npx skiddie@latest add glide-tab-bar
If you haven't set skiddie up yet, run init first (Installation).
Every skiddie component comes in two versions with the same props and motion. Pick one:
- NativeWind (default): styled with
className. Needs NativeWind v4.
- StyleSheet: plain React Native
StyleSheet, no styling library. Add --stylesheet.
$npx skiddie@latest add glide-tab-bar tokens.tsColors, radii, spacing, motion tailwind-preset.jsTurns tokens into sk-* classes cn.tsMerges your classes last types.tsShared prop contract styles.tsDefault classes per slot use-glide-indicator.tsShared logic (hook) tailwind.config.jsinit adds the skiddie preset and folder
Only the highlighted files are this component. core/ is shared by every skiddie component and copied once by
init.
The bar doesn't ship icons: each tab gets an icon render function, so any icon set works. The examples use
Phosphor, which matches the thin outline style:
npx expo install phosphor-react-native react-native-svg
Import each icon from its own file. Importing from 'phosphor-react-native' itself pulls every icon (about 1,500)
into your bundle.
import { HouseIcon } from 'phosphor-react-native/src/icons/House';
// app/(tabs)/_layout.tsximport { Tabs } from 'expo-router';import { GlideTabBar } from '@/components/skiddie/glide-tab-bar';import { createTabBarAdapter } from '@/components/skiddie/core/adapters/react-navigation';const TabBar = createTabBarAdapter(GlideTabBar, { haptics: 'selection' });export default function TabLayout() { return ( <Tabs tabBar={(props) => <TabBar {...props} />} screenOptions={{ headerShown: false }}> <Tabs.Screen name="index" options={{ title: 'Home' }} /> <Tabs.Screen name="search" options={{ title: 'Search', tabBarBadge: 3 }} /> </Tabs> );}
import { useState } from 'react';
import { BellIcon } from 'phosphor-react-native/src/icons/Bell';
import { HeartIcon } from 'phosphor-react-native/src/icons/Heart';
import { HouseIcon } from 'phosphor-react-native/src/icons/House';
import { MagnifyingGlassIcon } from 'phosphor-react-native/src/icons/MagnifyingGlass';
import { UserIcon } from 'phosphor-react-native/src/icons/User';
import { GlideTabBar } from '@/components/skiddie/glide-tab-bar';
import type { TabItem } from '@/components/skiddie/core/types';
const items: TabItem[] = [
{ key: 'home', label: 'Home', icon: ({ color, size }) => <HouseIcon color={color} size={size} /> },
{ key: 'search', label: 'Search', icon: ({ color, size }) => <MagnifyingGlassIcon color={color} size={size} /> },
{ key: 'likes', label: 'Likes', icon: ({ color, size }) => <HeartIcon color={color} size={size} /> },
{
key: 'alerts',
label: 'Notifications',
badge: true,
icon: ({ color, size }) => <BellIcon color={color} size={size} />,
},
{ key: 'me', label: 'Profile', icon: ({ color, size }) => <UserIcon color={color} size={size} /> },
];
export function Bar() {
const [tab, setTab] = useState('home');
return <GlideTabBar items={items} activeKey={tab} onChange={setTab} />;
}
The icon function gets focused, so you can switch weights for the active tab:
icon: ({ focused, color, size }) => <HouseIcon color={color} size={size} weight={focused ? 'fill' : 'regular'} />
Everything from the shared tab bar contract (items, activeKey, onChange,
onLongPress, className and classNames (NativeWind) or styles (StyleSheet), style, renderItem, animation, reducedMotion, safeArea,
insets, testID), plus:
| Prop | Type | Default | What it does |
|---|
showLabels | boolean | false | Show each label under its icon. Screen readers read labels either way. |
iconSize | number | 24 | Size passed to every icon function. |
The haptics prop is accepted but does nothing yet; it starts working when the shared haptics adapter lands.
In order of power:
-
Tokens. The underline uses indicator, active icons active, the rest inactive, the bar
background and border (core/tokens.ts). Change them there and every skiddie component follows.
-
Classes per part with classNames. Your classes are merged last, so they win:
<GlideTabBar
{...props}
classNames={{
container: 'px-0', // edge to edge instead of floating
bar: 'rounded-none border-x-0 border-b-0',
indicator: 'h-0.5 w-full rounded-none', // a full-width hairline
}}
/>
Slots: container, bar, item, itemActive, icon, label, labelActive, indicator, badge,
badgeText.
StyleSheet version: the same slots take style objects through styles:
<GlideTabBar {...props} styles={{ bar: { borderRadius: 0 }, indicator: { height: 2, width: '100%' } }} />
-
renderItem to draw each tab yourself. The underline keeps working, because it only needs the tab count.
-
animation for the motion:
<GlideTabBar {...props} animation={{ durationMs: 160 }} /> // snappier ease-out
<GlideTabBar {...props} animation={{ type: 'spring' }} /> // springy, overshoots a little
<GlideTabBar {...props} animation={{ type: 'spring', spring: { damping: 12 } }} /> // bouncier
Every tab is the same width (flex-1). The bar measures its row once (onLayout) and keeps the active tab's
index in a Reanimated shared value. On the UI thread, the underline's translateX is index × tab width, so a
tap starts a new ease-out from wherever the line is now, even mid-animation, and rotating the device just re-measures
without a jump. React only re-renders when the active tab changes, never per frame.
- The bar is a
tablist; each tab is a tab with selected and disabled state.
- Labels are read even when hidden, together with the selected state.
- Badges are part of the label: a count reads "Search, 3", a dot reads "Notifications, new".
- Tabs are at least 44pt tall and share the full bar width.
- With Reduce Motion on (iOS) or Remove animations (Android), the underline moves without animating. Force
it with
reducedMotion="always" or "never".
- RTL: tabs and the underline mirror automatically.
| Platform | Support |
|---|
| iOS | Yes |
| Android | Yes |
| Web (live preview) | Yes |
| Expo Go | TBD |
| Minimum Expo SDK | TBD |
| License | skiddie Free |
Works in Expo Go: it only needs Reanimated and safe-area-context, which Expo Go includes.