Kinetik UI

Scroll Tabs

.md

Tabs over swipeable pages: the underline follows the swipe and stretches between tabs as labels brighten.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add scroll-tabs

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/scroll-tabs.tsx
    View source files

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 tab elements in a tablist.
  • 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

PropTypeDefaultDescription
tabsrequiredreadonly ScrollTab[]—
initialIndexnumber0The tab shown first. Defaults to 0.
onIndexChange(index: number) => void—
hapticsbooleantrueA tick when the tab changes. Defaults to true.
testIDstring—
styleStyleProp<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 },
});