Text tabs in a rounded track; a pill slides to the active tab and the row scrolls when it overflows.
38·Top TabsNew
Dependencies
nativewindreact-native-reanimatedreact-native-workletsreact-native-safe-area-contexttailwindcssclsxtailwind-merge
Text tabs inside a soft gray track, with a white pill behind the active one. Tap another tab and the pill eases
across to it, stretching to its width on the way. When there are more tabs than fit, the row scrolls sideways and the
tapped tab glides to the middle. Use it under a screen's header for categories or filters. For the same pill without
the track, see Pill Tabs Plain.
The preview has two clips: the bar alone, and the bar in a music app's home screen. Switch with the arrows, a swipe,
the dots under the phone, or the left and right arrow keys. The music screen is only there to show the bar in context:
skiddie add installs just the bar.
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 pill-tabs
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.
| File | What it does |
|---|
| pill-tabs/index.tsx | The component |
| pill-tabs/styles.ts | Default classes per slot |
| pill-tabs/types.ts | Props, with docs |
| pill-tabs/use-pill-tabs.ts | Shared logic (hook) |
| core/tokens.ts | Colors, radii, spacing, motion |
| core/tailwind-preset.js | Turns tokens into sk-* classes |
| core/cn.ts | Merges your classes last |
| core/types.ts | Shared prop contract |
| tailwind.config.js | init 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.
No CLI? Install the dependencies and copy the files yourself. Pick the styling version:
1
Install the dependencies:
npx expo install nativewind react-native-reanimated react-native-worklets react-native-safe-area-context tailwindcss clsx tailwind-merge
2
Set up NativeWind v4, then add the skiddie preset and folder to tailwind.config.js:
module.exports = { content: [ './app/**/*.{js,jsx,ts,tsx}', './components/skiddie/**/*.{js,jsx,ts,tsx}', ], presets: [ require('nativewind/preset'), require('./components/skiddie/core/tailwind-preset'), ],};
3
Copy the shared files into components/skiddie/core/ (once, for every skiddie component):
components/skiddie/core/tokens.ts /** * skiddie design tokens: the single source of truth. * * `tailwind-preset.js` turns these into `sk-*` Tailwind classes (CSS variables, so light/dark * switch automatically). Components read this file directly only for values needed in JS, * e.g. inside Reanimated worklets. Edit values here, then restart Metro so Tailwind rebuilds. */export const tokens = { colors: { light: { background: '#FFFFFF', surface: '#F4F4F5', active: '#0A0A0A', inactive: '#A1A1AA', indicator: '#0A0A0A', badge: '#EF4444', badgeText: '#FFFFFF', border: '#E4E4E7', // Pill Tabs: the sliding pill inside the surface track, and the trackless version's pill on the background. pill: '#FFFFFF', pillPlain: '#F4F4F5', }, dark: { background: '#0A0A0A', surface: '#18181B', active: '#FAFAFA', inactive: '#71717A', indicator: '#FAFAFA', badge: '#EF4444', badgeText: '#FFFFFF', border: '#27272A', pill: '#3F3F46', pillPlain: '#27272A', }, }, radius: { bar: 28, card: 12, item: 20, indicator: 999 }, spacing: { barPaddingH: 12, barPaddingV: 8, itemPaddingV: 16, itemGap: 4, floatingInset: 16, // Default gap between a floating bottom bar and the bottom safe area, so it clears the home indicator. floatingOffset: 23, badgeOffsetX: 8, badgeOffsetY: 4, badgePaddingH: 4, // Gap between a highlight tile and its tab's edges. tileInset: 4, // Pill Tabs: horizontal padding inside each pill. pillPaddingH: 16, }, // Sizes in px. Use these, not Tailwind's rem classes (py-4, h-1…): NativeWind makes 1rem 14px on native and 16px // on web, so rem classes would differ from the StyleSheet versions (ADR 0026). size: { indicatorThickness: 4, badge: 16, badgeDot: 8, // The cutout bar's notch: opening width at the top edge, depth, and the dot inside it. notchWidth: 44, notchDepth: 10, notchDot: 6, }, typography: { label: { fontSize: 11, lineHeight: 14, letterSpacing: 0.2 }, badge: { fontSize: 10 }, // Top tab labels (Pill Tabs). tab: { fontSize: 15, lineHeight: 20, letterSpacing: -0.2 }, }, motion: { spring: { damping: 18, stiffness: 220, mass: 1 }, durationMs: 220 }, hitSlop: 8, minTouchTarget: 44,} as const;export type SkiddieTokens = typeof tokens;export type SkiddieColorName = keyof SkiddieTokens['colors']['light'];
components/skiddie/core/tailwind-preset.js // skiddie Tailwind preset for NativeWind v4 (ADR 0020).// Add to tailwind.config.js: presets: [require('nativewind/preset'), require('./components/skiddie/core/tailwind-preset')]// and add the skiddie folder to `content`, or the components render unstyled.//// Values come from tokens.ts. Tailwind loads configs through jiti, which transpiles the TS import.const { tokens } = require('./tokens.ts');/** '#0A0A0A' -> '10 10 10' (channels, so `/ <alpha-value>` opacity modifiers work) */function channels(hex) { const value = hex.replace('#', ''); const full = value.length === 3 ? value .split('') .map((c) => c + c) .join('') : value; const n = Number.parseInt(full, 16); return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;}/** camelCase token name -> kebab-case class/variable name: badgeText -> badge-text */function kebab(name) { return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);}function cssVars(scheme) { return Object.fromEntries( Object.entries(tokens.colors[scheme]).map(([name, hex]) => [`--sk-${kebab(name)}`, channels(hex)]), );}const colorNames = Object.keys(tokens.colors.light);/** @type {import('tailwindcss').Config} */module.exports = { theme: { extend: { colors: { sk: Object.fromEntries( colorNames.map((name) => [kebab(name), `rgb(var(--sk-${kebab(name)}) / <alpha-value>)`]), ), }, borderRadius: { 'sk-bar': `${tokens.radius.bar}px`, 'sk-card': `${tokens.radius.card}px`, 'sk-item': `${tokens.radius.item}px`, 'sk-indicator': `${tokens.radius.indicator}px`, }, spacing: { 'sk-bar-x': `${tokens.spacing.barPaddingH}px`, 'sk-bar-y': `${tokens.spacing.barPaddingV}px`, 'sk-gap': `${tokens.spacing.itemGap}px`, 'sk-inset': `${tokens.spacing.floatingInset}px`, 'sk-touch': `${tokens.minTouchTarget}px`, 'sk-item-y': `${tokens.spacing.itemPaddingV}px`, 'sk-badge-x': `${tokens.spacing.badgeOffsetX}px`, 'sk-badge-y': `${tokens.spacing.badgeOffsetY}px`, 'sk-badge-pad': `${tokens.spacing.badgePaddingH}px`, 'sk-tile': `${tokens.spacing.tileInset}px`, 'sk-pill-x': `${tokens.spacing.pillPaddingH}px`, 'sk-indicator': `${tokens.size.indicatorThickness}px`, 'sk-badge': `${tokens.size.badge}px`, 'sk-dot': `${tokens.size.badgeDot}px`, }, fontSize: { 'sk-label': [ `${tokens.typography.label.fontSize}px`, { lineHeight: `${tokens.typography.label.lineHeight}px`, letterSpacing: `${tokens.typography.label.letterSpacing}px`, }, ], 'sk-badge': `${tokens.typography.badge.fontSize}px`, 'sk-tab': [ `${tokens.typography.tab.fontSize}px`, { lineHeight: `${tokens.typography.tab.lineHeight}px`, letterSpacing: `${tokens.typography.tab.letterSpacing}px`, }, ], }, }, }, plugins: [ ({ addBase }) => addBase({ ':root': cssVars('light'), // System appearance (NativeWind's default `darkMode: 'media'`) '@media (prefers-color-scheme: dark)': { ':root': cssVars('dark') }, // Apps that switch manually with `darkMode: 'class'` '.dark:root': cssVars('dark'), }), ],};
components/skiddie/core/cn.ts import { type ClassValue, clsx } from 'clsx';import { extendTailwindMerge } from 'tailwind-merge';// tailwind-merge only knows Tailwind's own scales. Teach it the sk-* ones from tailwind-preset.js, or it drops// `text-sk-label` (a size) next to `text-sk-active` (a color) and can't let `rounded-none` replace `rounded-sk-bar`.// Keep these lists in step with the preset.const twMerge = extendTailwindMerge({ extend: { theme: { spacing: [ 'sk-bar-x', 'sk-bar-y', 'sk-gap', 'sk-inset', 'sk-touch', 'sk-item-y', 'sk-badge-x', 'sk-badge-y', 'sk-badge-pad', 'sk-tile', 'sk-pill-x', 'sk-indicator', 'sk-badge', 'sk-dot', ], borderRadius: ['sk-bar', 'sk-card', 'sk-item', 'sk-indicator'], }, classGroups: { 'font-size': [{ text: ['sk-label', 'sk-badge', 'sk-tab'] }], }, },});/** * Merge class names. Later classes win, so user `className` / `classNames` override * component defaults: cn('bg-sk-background px-2', className). */export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs));}
components/skiddie/core/types.ts import type { ReactNode } from 'react';import type { StyleProp, TextStyle, ViewStyle } from 'react-native';import type { EdgeInsets } from 'react-native-safe-area-context';/* * Shared prop contract for every tab-bar-like component (PLAN.md 2.3, ADR 0020). * Customization goes, in order of power: tokens → className / classNames → renderItem → animation. */export type TabIconRender = (p: { focused: boolean; color: string; size: number }) => ReactNode;export interface TabItem { /** Unique key; matches `activeKey` and is passed to `onChange`. */ key: string; /** Visible label, also the default accessibility label. */ label: string; icon?: TabIconRender; /** A count, a short string, or `true` for a dot. Announced to screen readers. */ badge?: number | string | boolean; accessibilityLabel?: string; testID?: string; disabled?: boolean;}export type TabBarSlot = | 'container' | 'bar' | 'item' | 'itemActive' | 'icon' | 'label' | 'labelActive' | 'indicator' | 'badge' | 'badgeText';export interface RenderItemProps { item: TabItem; focused: boolean; index: number; onPress: () => void;}export interface SpringConfig { damping?: number; stiffness?: number; mass?: number;}/** Everything a tab bar takes except styling; shared by the NativeWind and StyleSheet versions (ADR 0026). */export interface TabBarBehaviorProps { items: TabItem[]; activeKey: string; onChange: (key: string) => void; onLongPress?: (key: string) => void; /** Escape hatch; wins over classes and slot styles. */ style?: StyleProp<ViewStyle>; renderItem?: (p: RenderItemProps) => ReactNode; animation?: { type?: 'spring' | 'timing'; spring?: SpringConfig; durationMs?: number }; haptics?: boolean | 'light' | 'medium' | 'selection'; reducedMotion?: 'system' | 'always' | 'never'; /** Pad for the bottom safe area. Default `true`. */ safeArea?: boolean; /** Injected by the navigator adapter when available. */ insets?: EdgeInsets; testID?: string;}/** NativeWind version: style with classes. */export interface BaseTabBarProps<Slot extends string = TabBarSlot> extends TabBarBehaviorProps { /** Classes for the root. Merged last, so they win over defaults. */ className?: string; /** Classes per part. */ classNames?: Partial<Record<Slot, string>>;}/** Slots that render text; they take `TextStyle`, every other slot `ViewStyle`. */export type TextSlot = 'label' | 'labelActive' | 'badgeText';/** StyleSheet version: style with style objects. */export interface BaseTabBarStyleProps<Slot extends string = TabBarSlot> extends TabBarBehaviorProps { /** Styles per part, applied after the defaults, so they win. */ styles?: { [K in Slot]?: StyleProp<K extends TextSlot ? TextStyle : ViewStyle> };}
4
Copy the component into components/skiddie/pill-tabs/:
components/skiddie/pill-tabs/index.tsx import { cn } from '../core/cn';import { tokens } from '../core/tokens';import { useColorScheme } from 'nativewind';import { memo } from 'react';import { Pressable, ScrollView, Text, View } from 'react-native';import Animated from 'react-native-reanimated';import { useSafeAreaInsets } from 'react-native-safe-area-context';import { styles } from './styles';import type { PillTabsProps } from './types';import { usePillTabs } from './use-pill-tabs';/** Text tabs in a rounded track; a pill slides to the active one and the row scrolls when it overflows. NativeWind version. */export const PillTabs = memo(function PillTabs({ items, activeKey, onChange, onLongPress, className, classNames, style, renderItem, animation, reducedMotion, scrollable = true, iconSize = 18, safeArea = false, insets, testID,}: PillTabsProps) { const safe = useSafeAreaInsets(); // Icon colors are needed in JS, so they come from tokens. NativeWind's hook follows the system appearance and // apps that switch dark mode by hand. const { colorScheme } = useColorScheme(); const colors = tokens.colors[colorScheme === 'dark' ? 'dark' : 'light']; const top = safeArea ? (insets ?? safe).top : 0; const { indicatorStyle, onItemLayout, onViewportLayout, onContentSizeChange, scrollRef } = usePillTabs({ items, activeKey, animation, reducedMotion, }); return ( <View role="tablist" testID={testID} className={cn(styles.container, classNames?.container, className)} style={[{ paddingTop: top }, style]} > <View className={cn(styles.bar, classNames?.bar)}> <ScrollView ref={scrollRef} horizontal scrollEnabled={scrollable} showsHorizontalScrollIndicator={false} onLayout={onViewportLayout} onContentSizeChange={onContentSizeChange} contentContainerClassName="grow flex-row" > {/* Drawn first so it sits behind the labels. */} <View pointerEvents="none" className="absolute inset-0 flex-row"> <Animated.View style={indicatorStyle}> <View className={cn(styles.indicator, classNames?.indicator)} /> </Animated.View> </View> {items.map((item, index) => { const focused = item.key === activeKey; const onPress = () => onChange(item.key); if (renderItem) return ( <View key={item.key} onLayout={onItemLayout(index)} className={cn(!scrollable && 'flex-1')}> {renderItem({ item, focused, index, onPress })} </View> ); const hasBadge = item.badge !== undefined && item.badge !== false; const spoken = item.badge === true ? 'new' : item.badge; const label = item.accessibilityLabel ?? item.label; return ( <Pressable key={item.key} role="tab" accessibilityState={{ selected: focused, disabled: item.disabled }} accessibilityLabel={hasBadge ? `${label}, ${spoken}` : label} disabled={item.disabled} onPress={onPress} onLongPress={onLongPress ? () => onLongPress(item.key) : undefined} onLayout={onItemLayout(index)} testID={item.testID} className={cn( styles.item, !scrollable && 'flex-1', classNames?.item, focused && cn(styles.itemActive, classNames?.itemActive), item.disabled && 'cursor-auto opacity-40', )} > {item.icon && ( <View className={cn(styles.icon, classNames?.icon)}> {item.icon({ focused, color: focused ? colors.active : colors.inactive, size: iconSize })} </View> )} <Text numberOfLines={1} className={cn( styles.label, classNames?.label, focused && cn(styles.labelActive, classNames?.labelActive), )} > {item.label} </Text> {hasBadge && ( <View className={cn(styles.badge, item.badge === true && 'size-sk-dot min-w-0 px-0', classNames?.badge)} > {item.badge !== true && ( <Text className={cn(styles.badgeText, classNames?.badgeText)}>{item.badge}</Text> )} </View> )} </Pressable> ); })} </ScrollView> </View> </View> );});export default PillTabs;
components/skiddie/pill-tabs/styles.ts /** Default classes per slot, from sk-* token classes. Users override any slot with `classNames`. */export const styles = { // Inset from the screen edges; the top safe area is added in index.tsx when `safeArea` is on. container: 'px-sk-inset', // The gray track. overflow-hidden clips the scrolling row to its rounded ends. bar: 'overflow-hidden rounded-sk-indicator bg-sk-surface p-sk-tile', item: 'min-h-sk-touch cursor-pointer flex-row items-center justify-center gap-sk-gap px-sk-pill-x', itemActive: '', icon: 'items-center justify-center', label: 'text-sk-tab font-medium text-sk-inactive', labelActive: 'text-sk-active', // The pill: as tall as the tabs; its x and width come from the measured tab, in use-pill-tabs.ts. indicator: 'flex-1 rounded-sk-indicator bg-sk-pill', badge: 'min-w-sk-badge items-center justify-center rounded-sk-indicator bg-sk-badge px-sk-badge-pad', badgeText: 'text-center text-sk-badge font-semibold text-sk-badge-text',} as const;
components/skiddie/pill-tabs/types.ts import type { BaseTabBarProps, BaseTabBarStyleProps } from '../core/types';interface PillTabsOptions { /** * Let the row scroll sideways when the tabs don't fit, keeping the active one centered. Default `true`. With * `false`, the tabs share the width equally, like a segmented control. */ scrollable?: boolean; /** Icon size in points, for items that have an `icon`. Default `18`. */ iconSize?: number;}/** NativeWind version (`index.tsx`): style with `className` / `classNames`. */export interface PillTabsProps extends BaseTabBarProps, PillTabsOptions {}/** StyleSheet version (`stylesheet/index.tsx`): style with `styles`. */export interface PillTabsStyleProps extends BaseTabBarStyleProps, PillTabsOptions {}
components/skiddie/pill-tabs/use-pill-tabs.ts import { tokens } from '../core/tokens';import type { TabBarBehaviorProps } from '../core/types';import { useEffect, useRef } from 'react';import { I18nManager, type LayoutChangeEvent, type ScrollView } from 'react-native';import { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming,} from 'react-native-reanimated';type Options = Pick<TabBarBehaviorProps, 'items' | 'activeKey' | 'animation' | 'reducedMotion'>;type Box = { x: number; w: number };/** * The pill's motion and the row's scrolling, shared by both versions of the bar. * * Tabs have different widths, so each one reports its own x and width. A shared value holds the active *index*; * the worklet blends the two boxes it sits between, so the pill stretches as it slides, a tap mid-animation starts * from where the pill is, and re-measuring (rotation, font scale) moves it without animating. When the row * overflows, the active tab is scrolled to the middle of the viewport. */export function usePillTabs({ items, activeKey, animation, reducedMotion = 'system' }: Options) { const systemReduced = useReducedMotion(); const reduce = reducedMotion === 'always' || (reducedMotion === 'system' && systemReduced); const activeIndex = items.findIndex((item) => item.key === activeKey); const found = activeIndex >= 0; const rtl = I18nManager.isRTL; const boxes = useRef<Box[]>([]); const measured = useSharedValue<Box[]>([]); const contentWidth = useSharedValue(0); const position = useSharedValue(Math.max(activeIndex, 0)); const scrollRef = useRef<ScrollView>(null); const viewport = useRef(0); const content = useRef(0); // Ease-out by default: fast start, gentle stop, no overshoot. `animation={{ type: 'spring' }}` for a bounce. const type = animation?.type ?? 'timing'; const durationMs = animation?.durationMs ?? tokens.motion.durationMs; const damping = animation?.spring?.damping ?? tokens.motion.spring.damping; const stiffness = animation?.spring?.stiffness ?? tokens.motion.spring.stiffness; const mass = animation?.spring?.mass ?? tokens.motion.spring.mass; /** Scroll so the tab is centered, clamped to the row's ends. A no-op when everything fits. */ const center = (index: number, animated: boolean) => { const box = boxes.current[index]; const max = content.current - viewport.current; if (!box || viewport.current === 0 || max <= 0) return; const x = Math.min(Math.max(box.x + box.w / 2 - viewport.current / 2, 0), max); scrollRef.current?.scrollTo({ x, animated }); }; // biome-ignore lint/correctness/useExhaustiveDependencies: `center` reads refs only, so it isn't a dependency. useEffect(() => { if (!found) return; if (reduce) position.value = activeIndex; else if (type === 'spring') position.value = withSpring(activeIndex, { damping, stiffness, mass }); else position.value = withTiming(activeIndex, { duration: durationMs, easing: Easing.out(Easing.cubic) }); center(activeIndex, !reduce); }, [activeIndex, found, reduce, type, durationMs, damping, stiffness, mass, position]); const indicatorStyle = useAnimatedStyle(() => { const list = measured.value; const from = Math.floor(position.value); const to = Math.min(from + 1, list.length - 1); const a = list[from]; const b = list[to] ?? a; if (!found || !a || !b) return { opacity: 0, width: 0, transform: [{ translateX: 0 }] }; const t = position.value - from; const x = a.x + (b.x - a.x) * t; const width = a.w + (b.w - a.w) * t; return { opacity: 1, width, // x is measured from the left. In RTL the pill starts at the right edge (rows flip, transforms don't), so // move it left by the gap between its right side and the row's. transform: [{ translateX: rtl ? -(contentWidth.value - x - width) : x }], }; }); const onItemLayout = (index: number) => (event: LayoutChangeEvent) => { const { x, width } = event.nativeEvent.layout; const first = !boxes.current[index]; boxes.current[index] = { x, w: width }; measured.value = boxes.current.slice(0, items.length); // The first time the active tab is measured, jump the row to it (it may start off screen). if (first && index === activeIndex) center(index, false); }; const onViewportLayout = (event: LayoutChangeEvent) => { viewport.current = event.nativeEvent.layout.width; if (found) center(activeIndex, false); }; const onContentSizeChange = (width: number) => { content.current = width; contentWidth.value = width; if (found) center(activeIndex, false); }; return { indicatorStyle, onItemLayout, onViewportLayout, onContentSizeChange, scrollRef };}
5
Update the import paths to match your project, then use it as shown below.
import { useState } from 'react';import { View } from 'react-native';import { PillTabs } from '@/components/skiddie/pill-tabs';const items = [ { key: 'all', label: 'All' }, { key: 'music', label: 'Music' }, { key: 'podcasts', label: 'Podcasts' }, { key: 'audiobooks', label: 'Audiobooks' }, { key: 'live', label: 'Live' },];export function Screen() { const [tab, setTab] = useState('all'); return ( <View style={{ flex: 1 }}> {/* your header */} <PillTabs items={items} activeKey={tab} onChange={setTab} /> {/* the content for `tab` */} </View> );}
Give it more tabs than fit and the row scrolls. Tapping a tab slides the pill to it and scrolls that tab to the middle.
import { useState } from 'react';
import { PillTabs } from '@/components/skiddie/pill-tabs';
import type { TabItem } from '@/components/skiddie/core/types';
const items: TabItem[] = [
{ key: 'all', label: 'All' },
{ key: 'music', label: 'Music' },
{ key: 'podcasts', label: 'Podcasts' },
{ key: 'audiobooks', label: 'Audiobooks' },
{ key: 'live', label: 'Live' },
{ key: 'charts', label: 'Charts' },
{ key: 'new', label: 'New releases' },
];
export function Categories() {
const [tab, setTab] = useState('all');
return <PillTabs items={items} activeKey={tab} onChange={setTab} />;
}
For two to four fixed choices, turn scrolling off and the tabs share the width, like a segmented control:
<PillTabs items={[{ key: 'day', label: 'Day' }, { key: 'week', label: 'Week' }, { key: 'month', label: 'Month' }]} activeKey={range} onChange={setRange} scrollable={false} />
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 |
|---|
scrollable | boolean | true | Let the row scroll sideways when the tabs don't fit, keeping the active one centered. With false, the tabs share the width equally. |
iconSize | number | 18 | Size passed to icon, for tabs that have one. Icons are optional. |
safeArea defaults to false: the bar usually sits under a header. Set it to true when it's the first thing on
the screen, to pad for the status bar. The haptics prop is accepted but does nothing yet; it starts working when
the shared haptics adapter lands.
In order of power:
-
Tokens. The track uses surface, the pill pill (white in light mode, a raised gray in dark). Active labels use active, the rest inactive. Label size is the tab type token
(15 px) and pill padding pillPaddingH (16 px), in core/tokens.ts. Change them there and every skiddie
component follows.
-
Classes per part with classNames. Your classes are merged last, so they win:
<PillTabs
{...props}
classNames={{
container: 'px-0', // edge to edge
bar: 'rounded-sk-card', // a squarer track
indicator: 'rounded-sk-card', // and a matching pill
}}
/>
Slots: container, bar, item, itemActive, icon, label, labelActive, indicator (the pill),
badge, badgeText.
StyleSheet version: the same slots take style objects through styles.
-
renderItem to draw each tab yourself. The pill keeps working: it measures whatever you render.
-
animation for the motion:
<PillTabs {...props} animation={{ durationMs: 160 }} /> // snappier ease-out
<PillTabs {...props} animation={{ type: 'spring' }} /> // springy, overshoots a little
Tabs have different widths, so each one reports its own x and width (onLayout). The active tab's index lives
in a Reanimated shared value; on the UI thread the pill blends the two tabs it's between, so it stretches to the new
width while it slides. A tap mid-animation starts from where the pill is, and re-measuring (rotation, a bigger font
size) moves it without a jump. When the active tab changes, the row calls scrollTo once to center it. React
re-renders only when the active tab changes, never per frame. The pill is drawn first, so it sits behind the labels.
- The bar is a
tablist; each tab is a tab with selected and disabled state.
- Badges are part of the label: a count reads "Live, 3", a dot reads "Live, new".
- Tabs are at least 44pt tall. Labels follow the system font size; the pill re-measures.
- With Reduce Motion on (iOS) or Remove animations (Android), the pill and the row move without animating.
Force it with
reducedMotion="always" or "never".
- RTL: tabs, the pill and the scroll direction mirror. Not checked on a device yet.
| 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.