Scroll Tabs
Tabs over swipeable pages: the underline follows the swipe and stretches between tabs as labels brighten.
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
View,
type NativeScrollEvent,
type NativeSyntheticEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ScrollTab = {
key: string;
title: string;
content: ReactNode;
};
export type ScrollTabsProps = {
tabs: readonly ScrollTab[];
/** The tab shown first. Defaults to 0. */
initialIndex?: number;
onIndexChange?: (index: number) => void;
/** A tick when the tab changes. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const TAB_H = 48;
const PAD = 16;
const LINE = 3;
/**
* Tabs over pages you can swipe. The underline is tied to the swipe itself:
* it slides between tabs, stretching a little in the middle of the move, the
* labels brighten as their page arrives, and the strip scrolls to keep the
* current tab in view.
*/
export function ScrollTabs({
tabs,
initialIndex = 0,
onIndexChange,
haptics = true,
testID,
style,
}: ScrollTabsProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const strip = useRef<ScrollView>(null);
const pager = useAnimatedRef<Animated.ScrollView>();
const [index, setIndex] = useState(initialIndex);
const [width, setWidth] = useState(0);
const [stripW, setStripW] = useState(0);
const [spots, setSpots] = useState<{ x: number; w: number }[]>([]);
const xs = useSharedValue<number[]>([]);
const ws = useSharedValue<number[]>([]);
const progress = useSharedValue(initialIndex);
// Underline spans: the label inside each tab's padding.
const place = (i: number, x: number, w: number) => {
setSpots((prev) => {
if (prev[i]?.x === x && prev[i]?.w === w) return prev;
const next = [...prev];
next[i] = { x, w };
return next;
});
};
useEffect(() => {
xs.set(spots.map((s) => s?.x ?? 0));
ws.set(spots.map((s) => s?.w ?? 0));
}, [spots, xs, ws]);
const reveal = (i: number) => {
const spot = spots[i];
if (!spot || stripW === 0) return;
strip.current?.scrollTo({
x: Math.max(0, spot.x + spot.w / 2 - stripW / 2),
animated: !reduced,
});
};
const settle = (i: number) => {
if (i === index) return;
haptic('tick');
setIndex(i);
reveal(i);
onIndexChange?.(i);
};
const select = (i: number) => {
pager.current?.scrollTo({ x: i * width, animated: !reduced });
if (reduced || width === 0) progress.set(i);
settle(i);
};
// The web ignores contentOffset, so the first page is also set once there is a width.
const opened = useRef(false);
useEffect(() => {
if (width === 0 || opened.current) return;
opened.current = true;
if (initialIndex > 0) pager.current?.scrollTo({ x: initialIndex * width, animated: false });
}, [width, initialIndex, pager]);
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
if (width > 0) progress.set(e.contentOffset.x / width);
},
});
const onEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (width === 0) return;
settle(Math.round(e.nativeEvent.contentOffset.x / width));
};
const line = useAnimatedStyle(() => {
const x = xs.get();
const w = ws.get();
if (x.length === 0) return { opacity: 0 };
const p = Math.max(0, Math.min(x.length - 1, progress.get()));
const i = Math.floor(p);
const j = Math.min(x.length - 1, i + 1);
const f = p - i;
const left = (x[i] ?? 0) + ((x[j] ?? 0) - (x[i] ?? 0)) * f;
const width = (w[i] ?? 0) + ((w[j] ?? 0) - (w[i] ?? 0)) * f;
// Halfway between two tabs the line is a little longer, like it is being pulled.
const pull = Math.sin(f * Math.PI) * 14;
return { opacity: 1, left: left - pull / 2, width: width + pull };
});
return (
<View testID={testID} style={style}>
<View style={[styles.stripBox, { borderBottomColor: colors.border }]}>
<ScrollView
ref={strip}
horizontal
showsHorizontalScrollIndicator={false}
onLayout={(e) => setStripW(e.nativeEvent.layout.width)}
accessibilityRole="tablist"
>
{tabs.map((tab, i) => (
<Tab
key={tab.key}
title={tab.title}
index={i}
selected={i === index}
progress={progress}
onPress={() => select(i)}
onPlace={(x, w) => place(i, x, w)}
/>
))}
<Animated.View
pointerEvents="none"
style={[styles.line, { backgroundColor: colors.text }, line]}
/>
</ScrollView>
</View>
<View
testID={testID ? `${testID}-pages-box` : undefined}
style={styles.pages}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
{width > 0 ? (
<Animated.ScrollView
ref={pager}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={onScroll}
onMomentumScrollEnd={onEnd}
scrollEventThrottle={16}
contentOffset={{ x: initialIndex * width, y: 0 }}
testID={testID ? `${testID}-pages` : undefined}
>
{tabs.map((tab, i) => (
<View
key={tab.key}
style={{ width }}
accessibilityElementsHidden={i !== index}
importantForAccessibility={i === index ? 'auto' : 'no-hide-descendants'}
>
{tab.content}
</View>
))}
</Animated.ScrollView>
) : null}
</View>
</View>
);
}
type TabProps = {
title: string;
index: number;
selected: boolean;
progress: SharedValue<number>;
onPress: () => void;
onPlace: (x: number, w: number) => void;
};
function Tab({ title, index, selected, progress, onPress, onPlace }: TabProps) {
const { colors, font } = useKinetikTheme();
const label = useAnimatedStyle(() => ({
color: interpolateColor(
Math.min(1, Math.abs(progress.get() - index)),
[0, 1],
[colors.text, colors.textSubtle],
),
}));
return (
<Pressable
onPress={onPress}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout;
onPlace(x + PAD, width - PAD * 2);
}}
accessibilityRole="tab"
accessibilityLabel={title}
accessibilityState={{ selected }}
style={styles.tab}
>
<Animated.Text style={[{ fontSize: font.size.md, fontWeight: font.weight.semibold }, label]}>
{title}
</Animated.Text>
</Pressable>
);
}
const styles = StyleSheet.create({
stripBox: { borderBottomWidth: StyleSheet.hairlineWidth },
tab: { height: TAB_H, paddingHorizontal: PAD, justifyContent: 'center' },
line: { position: 'absolute', bottom: 0, height: LINE, borderRadius: LINE },
pages: { minHeight: 1 },
});Installation
npx kinetik-ui add scroll-tabspnpm dlx kinetik-ui add scroll-tabsbunx kinetik-ui add scroll-tabsyarn dlx kinetik-ui add scroll-tabsThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/scroll-tabs.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/scroll-tabs.tsx
Usage
import { ScrollTabs } from '@/components/kinetik/scroll-tabs';
<ScrollTabs
tabs={[
{ key: 'all', title: 'All', content: <AllFeed /> },
{ key: 'photos', title: 'Photos', content: <Photos /> },
{ key: 'videos', title: 'Videos', content: <Videos /> },
]}
onIndexChange={setIndex}
/>;When to use
- Sibling views of the same kind that people swipe between: feed filters, categories, inbox folders.
- More tabs than fit across the screen; the strip scrolls and keeps the current tab in view.
When not to use
- The app's main sections. Use a bottom tab bar (Dock Bar or Liquid Tabs).
- Two or three options that filter one list in place. Use Filter Chips or a segmented control.
Notes
- The underline follows the swipe frame by frame on the UI thread and stretches slightly halfway between tabs.
- Only the current page is exposed to screen readers; tabs are
tabelements in atablist. - Every page is rendered; keep heavy content lazy inside it if there are many tabs.
- With reduced motion a tab tap jumps straight to its page.
Props
ScrollTabsProps
| Prop | Type | Default | Description |
|---|---|---|---|
tabsrequired | readonly ScrollTab[] | — | |
initialIndex | number | 0 | The tab shown first. Defaults to 0. |
onIndexChange | (index: number) => void | — | |
haptics | boolean | true | A tick when the tab changes. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Two native scroll views; the underline and labels read the page scroll offset on the UI thread. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 144 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Tapping a tab jumps to its page instead of scrolling.
- Screen readers
- A tab list whose tabs have a selected state; only the current page is exposed.
- Touch target
- Tabs are 48 pt tall and as wide as their label plus 32 pt.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/scroll-tabs.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
View,
type NativeScrollEvent,
type NativeSyntheticEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ScrollTab = {
key: string;
title: string;
content: ReactNode;
};
export type ScrollTabsProps = {
tabs: readonly ScrollTab[];
/** The tab shown first. Defaults to 0. */
initialIndex?: number;
onIndexChange?: (index: number) => void;
/** A tick when the tab changes. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const TAB_H = 48;
const PAD = 16;
const LINE = 3;
/**
* Tabs over pages you can swipe. The underline is tied to the swipe itself:
* it slides between tabs, stretching a little in the middle of the move, the
* labels brighten as their page arrives, and the strip scrolls to keep the
* current tab in view.
*/
export function ScrollTabs({
tabs,
initialIndex = 0,
onIndexChange,
haptics = true,
testID,
style,
}: ScrollTabsProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const strip = useRef<ScrollView>(null);
const pager = useAnimatedRef<Animated.ScrollView>();
const [index, setIndex] = useState(initialIndex);
const [width, setWidth] = useState(0);
const [stripW, setStripW] = useState(0);
const [spots, setSpots] = useState<{ x: number; w: number }[]>([]);
const xs = useSharedValue<number[]>([]);
const ws = useSharedValue<number[]>([]);
const progress = useSharedValue(initialIndex);
// Underline spans: the label inside each tab's padding.
const place = (i: number, x: number, w: number) => {
setSpots((prev) => {
if (prev[i]?.x === x && prev[i]?.w === w) return prev;
const next = [...prev];
next[i] = { x, w };
return next;
});
};
useEffect(() => {
xs.set(spots.map((s) => s?.x ?? 0));
ws.set(spots.map((s) => s?.w ?? 0));
}, [spots, xs, ws]);
const reveal = (i: number) => {
const spot = spots[i];
if (!spot || stripW === 0) return;
strip.current?.scrollTo({
x: Math.max(0, spot.x + spot.w / 2 - stripW / 2),
animated: !reduced,
});
};
const settle = (i: number) => {
if (i === index) return;
haptic('tick');
setIndex(i);
reveal(i);
onIndexChange?.(i);
};
const select = (i: number) => {
pager.current?.scrollTo({ x: i * width, animated: !reduced });
if (reduced || width === 0) progress.set(i);
settle(i);
};
// The web ignores contentOffset, so the first page is also set once there is a width.
const opened = useRef(false);
useEffect(() => {
if (width === 0 || opened.current) return;
opened.current = true;
if (initialIndex > 0) pager.current?.scrollTo({ x: initialIndex * width, animated: false });
}, [width, initialIndex, pager]);
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
if (width > 0) progress.set(e.contentOffset.x / width);
},
});
const onEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (width === 0) return;
settle(Math.round(e.nativeEvent.contentOffset.x / width));
};
const line = useAnimatedStyle(() => {
const x = xs.get();
const w = ws.get();
if (x.length === 0) return { opacity: 0 };
const p = Math.max(0, Math.min(x.length - 1, progress.get()));
const i = Math.floor(p);
const j = Math.min(x.length - 1, i + 1);
const f = p - i;
const left = (x[i] ?? 0) + ((x[j] ?? 0) - (x[i] ?? 0)) * f;
const width = (w[i] ?? 0) + ((w[j] ?? 0) - (w[i] ?? 0)) * f;
// Halfway between two tabs the line is a little longer, like it is being pulled.
const pull = Math.sin(f * Math.PI) * 14;
return { opacity: 1, left: left - pull / 2, width: width + pull };
});
return (
<View testID={testID} style={style}>
<View style={[styles.stripBox, { borderBottomColor: colors.border }]}>
<ScrollView
ref={strip}
horizontal
showsHorizontalScrollIndicator={false}
onLayout={(e) => setStripW(e.nativeEvent.layout.width)}
accessibilityRole="tablist"
>
{tabs.map((tab, i) => (
<Tab
key={tab.key}
title={tab.title}
index={i}
selected={i === index}
progress={progress}
onPress={() => select(i)}
onPlace={(x, w) => place(i, x, w)}
/>
))}
<Animated.View
pointerEvents="none"
style={[styles.line, { backgroundColor: colors.text }, line]}
/>
</ScrollView>
</View>
<View
testID={testID ? `${testID}-pages-box` : undefined}
style={styles.pages}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
>
{width > 0 ? (
<Animated.ScrollView
ref={pager}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={onScroll}
onMomentumScrollEnd={onEnd}
scrollEventThrottle={16}
contentOffset={{ x: initialIndex * width, y: 0 }}
testID={testID ? `${testID}-pages` : undefined}
>
{tabs.map((tab, i) => (
<View
key={tab.key}
style={{ width }}
accessibilityElementsHidden={i !== index}
importantForAccessibility={i === index ? 'auto' : 'no-hide-descendants'}
>
{tab.content}
</View>
))}
</Animated.ScrollView>
) : null}
</View>
</View>
);
}
type TabProps = {
title: string;
index: number;
selected: boolean;
progress: SharedValue<number>;
onPress: () => void;
onPlace: (x: number, w: number) => void;
};
function Tab({ title, index, selected, progress, onPress, onPlace }: TabProps) {
const { colors, font } = useKinetikTheme();
const label = useAnimatedStyle(() => ({
color: interpolateColor(
Math.min(1, Math.abs(progress.get() - index)),
[0, 1],
[colors.text, colors.textSubtle],
),
}));
return (
<Pressable
onPress={onPress}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout;
onPlace(x + PAD, width - PAD * 2);
}}
accessibilityRole="tab"
accessibilityLabel={title}
accessibilityState={{ selected }}
style={styles.tab}
>
<Animated.Text style={[{ fontSize: font.size.md, fontWeight: font.weight.semibold }, label]}>
{title}
</Animated.Text>
</Pressable>
);
}
const styles = StyleSheet.create({
stripBox: { borderBottomWidth: StyleSheet.hairlineWidth },
tab: { height: TAB_H, paddingHorizontal: PAD, justifyContent: 'center' },
line: { position: 'absolute', bottom: 0, height: LINE, borderRadius: LINE },
pages: { minHeight: 1 },
});