Pill Tabs without the track: just the sliding pill.
39·Top TabsNew
Dependencies
nativewindreact-native-reanimatedreact-native-workletsreact-native-safe-area-contexttailwindcssclsxtailwind-merge
Pill Tabs without the track: just a soft pill behind the active label, sliding and
stretching to the tapped tab. The row runs edge to edge, so tabs scroll under the screen's sides, and its content is
inset to line up with the rest of the screen. Use it where a track would feel heavy, such as filters over a feed.
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-plain
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-plain/index.tsx | The component |
| pill-tabs-plain/styles.ts | Default classes per slot |
| pill-tabs-plain/types.ts | Props, with docs |
| pill-tabs-plain/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-plain/:
components/skiddie/pill-tabs-plain/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 { PillTabsPlainProps } from './types';import { usePillTabs } from './use-pill-tabs';/** Text tabs with a pill that slides to the active one, no track; the row scrolls when it overflows. NativeWind version. */export const PillTabsPlain = memo(function PillTabsPlain({ items, activeKey, onChange, onLongPress, className, classNames, style, renderItem, animation, reducedMotion, scrollable = true, iconSize = 18, safeArea = false, insets, testID,}: PillTabsPlainProps) { 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 px-sk-inset" > {/* 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 PillTabsPlain;
components/skiddie/pill-tabs-plain/styles.ts /** Default classes per slot, from sk-* token classes. Users override any slot with `classNames`. */export const styles = { // Edge to edge, so the row scrolls under the screen edges; the top safe area is added in index.tsx. container: '', // No track. The row's content is inset from the screen edges (see index.tsx), so the first pill lines up with // the screen's content. bar: '', 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-plain', 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-plain/types.ts import type { BaseTabBarProps, BaseTabBarStyleProps } from '../core/types';interface PillTabsPlainOptions { /** * 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 PillTabsPlainProps extends BaseTabBarProps, PillTabsPlainOptions {}/** StyleSheet version (`stylesheet/index.tsx`): style with `styles`. */export interface PillTabsPlainStyleProps extends BaseTabBarStyleProps, PillTabsPlainOptions {}
components/skiddie/pill-tabs-plain/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 { PillTabsPlain } from '@/components/skiddie/pill-tabs-plain';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 */} <PillTabsPlain 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 { PillTabsPlain } from '@/components/skiddie/pill-tabs-plain';
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 <PillTabsPlain 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:
<PillTabsPlain 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. There is no track; the pill uses pillPlain (a light gray in light mode, a dark 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:
<PillTabsPlain
{...props}
classNames={{
indicator: 'bg-sk-pill', // a white pill, for a gray screen
label: 'font-semibold'
}}
/>
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:
<PillTabsPlain {...props} animation={{ durationMs: 160 }} /> // snappier ease-out
<PillTabsPlain {...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.