Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Tabs
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsNavigationTabs

Tabs

Scan with Expo Go

Tabs with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

Underline tabs whose indicator moves like liquid, leading edge first.

<Tabs />4 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
items

Tabs and (optionally) their content panes. `icon` is a node or `({ active, color }) => node`.

{ value, label, icon?, badge?, content?, disabled?, accessibilityLabel? }[]—
value / defaultValue

Active tab (controlled / uncontrolled).

stringfirst enabled tab
onValueChange

Called when a tab is pressed or swiped to.

(value: string) => void—
size

36 / 44pt tall (sm keeps a 44pt hit area).

"sm" | "md""md"
scrollable

`true` sizes tabs to labels and scrolls; `false` gives equal widths. Default fills the width and scrolls only when tabs overflow, keeping the active tab centred.

booleanauto
swipeable

Drag the content sideways to change tab; the underline tracks the drag.

booleantrue
transition

How content changes on a tap.

"slide" | "fade" | "none""slide"
haptics

Selection tick on change.

booleantrue
accent

Underline and active badge colour.

stringtheme accent
variant

Deprecated: `pill` and `soft` render as `underline` — use SegmentedControl for a pill switch.

"underline""underline"
contentStyle

Style of each content pane.

StyleProp<ViewStyle>—
style

Style for the root view wrapping the tab bar and content.

StyleProp<ViewStyle>—

Behaviour & accessibility#

Touch
Tap a tab or drag the content panes sideways; the underline follows your finger, with rubber-band resistance at the ends.
Motion
The underline is measured to each label and springs with the leading edge first. Badges pop, and an overflowing bar scrolls to centre the active tab.
Reduced motion
Springs become short timings, the bar scrolls without animating and badges change without the pop.
Haptics
A selection tick on every change; turn it off with haptics={false}.
Screen readers
The bar is a tablist of tabs with selected and disabled state; badges are read with the label, and inactive panes are hidden.

Source#

PreviousProgress StepsNextNumber Ticker

Did you like the content?

Coding agents

Chat apps

Other

9:41

Underline

Tabs

Manual installation
  1. Install dependencies

    terminal
    npx expo install expo-haptics
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/tabs.tsx540 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { Tabs } from "@/components/ui/tabs";

    See Usage for a full example.

Example.tsx
import { Tabs } from '@/components/ui/tabs'; <Tabs  items={[    { value: 'overview', label: 'Overview', content: <Overview /> },    { value: 'reviews', label: 'Reviews', badge: 128, content: <Reviews /> },  ]}/>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/tabs.tsx
import * as Haptics from 'expo-haptics';import React, { useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';import {  AccessibilityInfo,  Animated,  PanResponder,  Pressable,  ScrollView,  StyleSheet,  View,  type LayoutChangeEvent,  type StyleProp,  type ViewStyle,} from 'react-native'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; /** * Tabs are underline tabs with content panes. `pill` and `soft` are @deprecated and render as * `underline` — use SegmentedControl for a pill switch. */export type TabsVariant = 'underline' | 'pill' | 'soft'; export interface TabItem {  value: string;  label: string;  /** Icon node, or a function that receives the current state and text colour. */  icon?: React.ReactNode | ((state: { active: boolean; color: string }) => React.ReactNode);  /** Count or short label shown in a small bubble beside the label. */  badge?: number | string;  /** What to show below the bar while this tab is active. */  content?: React.ReactNode;  disabled?: boolean;  accessibilityLabel?: string;} export interface TabsProps {  items: TabItem[];  value?: string;  defaultValue?: string;  onValueChange?: (value: string) => void;  /** @deprecated Only `underline` remains; `pill` and `soft` render as `underline`. */  variant?: TabsVariant;  /** 'sm' 36pt · 'md' 44pt tall. */  size?: 'sm' | 'md';  /**   * `true`: tabs size to their labels and scroll. `false`: equal widths, never scroll.   * Default: fill the width while they fit, scroll once they overflow.   */  scrollable?: boolean;  /** Drag the content sideways to move between tabs (default true). */  swipeable?: boolean;  /** How content changes on a tap: slide the panes, cross-fade, or swap instantly. */  transition?: 'slide' | 'fade' | 'none';  /** Selection tick on change (default true). */  haptics?: boolean;  accent?: string;  style?: StyleProp<ViewStyle>;  /** Style of each content pane. */  contentStyle?: StyleProp<ViewStyle>;} type Box = { x: number; w: number }; // Liquid move: the edge in the direction of travel leads, the other one trails behind.const LEAD = motion.snappy;const TRAIL = { damping: 20, stiffness: 150, mass: 0.9 };const UNDERLINE = 3; /** * Underline tabs with swipeable content panes. The underline is measured to each label and moves * like liquid — leading edge first, trailing edge catching up — and while you drag the panes it * follows your finger, stretching toward the next label. Count badges pop, the bar scrolls (and * keeps the active tab centred) once the tabs overflow, and every change ticks a haptic. */export function Tabs({  items,  value,  defaultValue,  onValueChange,  size = 'md',  scrollable,  swipeable = true,  transition = 'slide',  haptics = true,  accent,  style,  contentStyle,}: TabsProps) {  const { colors, radius, dark } = useKit();  const reduce = useReduceMotion();  const tint = accent ?? colors.accent;  const [inner, setInner] = useState(defaultValue ?? items.find((t) => !t.disabled)?.value);  const active = value ?? inner;  const index = Math.max(0, items.findIndex((t) => t.value === active));  const hasContent = items.some((t) => t.content !== undefined);  const mode: 'fill' | 'auto' | 'scroll' = scrollable === true ? 'scroll' : scrollable === false ? 'fill' : 'auto';   // ---- measurements ----  const tabs = useRef<Box[]>([]);  const labels = useRef<Box[]>([]);  const heights = useRef<number[]>([]);  const [measured, setMeasured] = useState(0);  const bump = () => setMeasured((n) => n + 1);  const [paneW, setPaneW] = useState(0);  const [barW, setBarW] = useState(0);  const [contentW, setContentW] = useState(0);  const overflow = mode !== 'fill' && contentW > barW + 1;   // ---- animated values ----  // Underline edges + pane height: JS driver (left / width / height).  const lx = useRef(new Animated.Value(0)).current;  const rx = useRef(new Animated.Value(0)).current;  const hv = useRef(new Animated.Value(0)).current;  // Pager offset + fade: native driver, on the pager row.  const pan = useRef(new Animated.Value(0)).current;  const fade = useRef(new Animated.Value(1)).current;  const [lineShown, setLineShown] = useState(false);  const [heightShown, setHeightShown] = useState(false);  const linePlaced = useRef(false);  const pagerPlaced = useRef(false);  const lastIndex = useRef(index);  const release = useRef({ v: 0, dragged: false });  const paging = useRef(false); // pager spring in flight (its JS value is stale until it ends)  const [settle, setSettle] = useState(0);  const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);   const edgesOf = (i: number): [number, number] | null => {    const t = tabs.current[i];    const l = labels.current[i];    return t && l ? [t.x + l.x, t.x + l.x + l.w] : null;  };   const select = (t: TabItem) => {    if (t.disabled || t.value === active) return;    if (haptics) Haptics.selectionAsync().catch(() => {});    if (value === undefined) setInner(t.value);    onValueChange?.(t.value);  };   // Go to the active tab: underline, pager, height, bar scroll. Also runs after a drag ends  // (settle) so a controlled parent that ignores the change still snaps back.  useEffect(() => {    const e = edgesOf(index);    // First placement happens in the layout callbacks (placeFirst); from then on, animate.    if (e && linePlaced.current) {      if (reduce) {        Animated.parallel([          Animated.timing(lx, { toValue: e[0], duration: 160, useNativeDriver: false }),          Animated.timing(rx, { toValue: e[1], duration: 160, useNativeDriver: false }),        ]).start();      } else {        const right = index >= lastIndex.current;        Animated.parallel([          Animated.spring(lx, { toValue: e[0], useNativeDriver: false, ...(right ? TRAIL : LEAD) }),          Animated.spring(rx, { toValue: e[1], useNativeDriver: false, ...(right ? LEAD : TRAIL) }),        ]).start();      }    }     const { v, dragged } = release.current;    release.current = { v: 0, dragged: false };    if (paneW) {      const to = -index * paneW;      if (!pagerPlaced.current || transition === 'none') {        pan.setValue(to);        pagerPlaced.current = true;      } else if (transition === 'fade' && !dragged && lastIndex.current !== index) {        pan.setValue(to);        fade.setValue(0);        Animated.timing(fade, { toValue: 1, duration: 200, useNativeDriver: true }).start();      } else if (reduce) {        Animated.timing(pan, { toValue: to, duration: 180, useNativeDriver: true }).start();      } else {        paging.current = true;        Animated.spring(pan, { toValue: to, velocity: v, useNativeDriver: true, ...motion.snappy, damping: 24 }).start(          ({ finished }) => {            if (finished) paging.current = false;          }        );      }    }     const h = heights.current[index];    if (h && heightShown) {      if (reduce) {        Animated.timing(hv, { toValue: h, duration: 180, useNativeDriver: false }).start();      } else {        Animated.spring(hv, { toValue: h, useNativeDriver: false, ...motion.gentle }).start();      }    }     const t = tabs.current[index];    if (overflow && t && lastIndex.current !== index) {      const x = Math.max(0, Math.min(contentW - barW, t.x + t.w / 2 - barW / 2));      scrollRef.current?.scrollTo({ x, animated: !reduce });    }    lastIndex.current = index;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [index, settle, paneW, measured]);   // ---- swipe between panes ----  const live = useRef({ index, items, paneW, swipe: false, select, edgesOf });  live.current = { index, items, paneW, swipe: swipeable && hasContent && paneW > 0, select, edgesOf };  const drag = useRef({ base: 0, off: 0 });  const neighbour = (i: number, dir: 1 | -1) => {    const t = live.current.items[i + dir];    return t && !t.disabled ? i + dir : -1;  };   const responder = useMemo(    () =>      PanResponder.create({        onMoveShouldSetPanResponder: (_, g) => live.current.swipe && Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy) * 1.4,        onPanResponderTerminationRequest: () => false,        onPanResponderGrant: () => {          const { index: i, paneW: W } = live.current;          lx.stopAnimation();          rx.stopAnimation();          hv.stopAnimation();          // Grabbed mid-flight: continue from where the pane is; at rest it sits exactly on its slot.          drag.current.base = 0;          if (paging.current) pan.stopAnimation((v) => (drag.current.base = Math.max(-W, Math.min(W, v + i * W))));          paging.current = false;          drag.current.off = drag.current.base;        },        onPanResponderMove: (_, g) => {          const { index: i, paneW: W, edgesOf: edges } = live.current;          let off = drag.current.base + g.dx;          const prev = neighbour(i, -1);          const next = neighbour(i, 1);          if (off > 0 && prev < 0) off = rubber(off, W);          if (off < 0 && next < 0) off = -rubber(-off, W);          off = Math.max(-W, Math.min(W, off));          drag.current.off = off;          pan.setValue(-i * W + off);           // Underline + height follow the finger toward the neighbour.          const j = off < 0 ? next : prev;          const a = edges(i);          const b = j >= 0 ? edges(j) : null;          if (!a || !b) return;          const p = Math.min(1, Math.abs(off) / W);          const lead = 1 - (1 - p) * (1 - p); // ease-out: the leading edge runs ahead          const trail = p * p; // ease-in: the trailing edge hangs back          const toRight = j > i;          lx.setValue(a[0] + (b[0] - a[0]) * (toRight ? trail : lead));          rx.setValue(a[1] + (b[1] - a[1]) * (toRight ? lead : trail));          const ha = heights.current[i];          const hb = heights.current[j];          if (ha && hb) hv.setValue(ha + (hb - ha) * p);        },        onPanResponderRelease: (_, g) => finish(g.vx),        onPanResponderTerminate: () => finish(0),      }),    // eslint-disable-next-line react-hooks/exhaustive-deps    []  );   const finish = (vx: number) => {    const { index: i, paneW: W, items: list, select: pick } = live.current;    const off = drag.current.off;    let target = i;    if (off < 0 && (off < -W * 0.22 || vx < -0.35)) target = neighbour(i, 1);    else if (off > 0 && (off > W * 0.22 || vx > 0.35)) target = neighbour(i, -1);    if (target < 0) target = i;    release.current = { v: vx, dragged: true };    if (target !== i) pick(list[target]);    setSettle((n) => n + 1);  };   // ---- layout callbacks ----  // The first underline position and pane height are set here, not in an effect, so the first  // frame after measuring is already right.  const placeFirst = () => {    const i = live.current.index;    const e = edgesOf(i);    if (!linePlaced.current && e) {      lx.setValue(e[0]);      rx.setValue(e[1]);      linePlaced.current = true;      lastIndex.current = i;      setLineShown(true);    }    const h = heights.current[i];    if (!heightShown && h) {      hv.setValue(h);      setHeightShown(true);    }  };  const onTabLayout = (i: number) => (e: LayoutChangeEvent) => {    const { x, width } = e.nativeEvent.layout;    const p = tabs.current[i];    if (p && p.x === x && p.w === width) return;    tabs.current[i] = { x, w: width };    placeFirst();    bump();  };  const onLabelLayout = (i: number) => (e: LayoutChangeEvent) => {    const { x, width } = e.nativeEvent.layout;    const p = labels.current[i];    if (p && p.x === x && p.w === width) return;    labels.current[i] = { x, w: width };    placeFirst();    bump();  };  const onPaneLayout = (i: number) => (e: LayoutChangeEvent) => {    const h = Math.ceil(e.nativeEvent.layout.height);    if (heights.current[i] === h) return;    heights.current[i] = h;    placeFirst();    bump();  };   const h = size === 'sm' ? 36 : 44;  const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);   const row = (    <View accessibilityRole="tablist" style={[styles.row, mode !== 'scroll' && styles.grow]}>      {items.map((t, i) => (        <Tab          key={t.value}          item={t}          active={i === index}          mode={mode}          height={h}          fontSize={size === 'sm' ? 13.5 : 14.5}          tint={tint}          reduce={reduce}          onPress={() => select(t)}          onLayout={onTabLayout(i)}          onLabelLayout={onLabelLayout(i)}        />      ))}      <Animated.View        pointerEvents="none"        style={[          styles.underline,          {            left: lx,            width: Animated.subtract(rx, lx),            opacity: lineShown ? 1 : 0,            backgroundColor: tint,            borderTopLeftRadius: radius.capsule(UNDERLINE * 2),            borderTopRightRadius: radius.capsule(UNDERLINE * 2),          },        ]}      />    </View>  );   // ---- panes ----  let panes: React.ReactNode = null;  if (hasContent) {    const current = items[index];    panes = (      <Animated.View        {...responder.panHandlers}        onLayout={(e) => {          const w = e.nativeEvent.layout.width;          if (Math.abs(w - paneW) > 0.5) setPaneW(w);        }}        style={[styles.viewport, heightShown && paneW ? { height: hv } : null]}      >        {paneW ? (          <Animated.View            style={[              styles.pager,              { width: paneW * items.length, opacity: fade, transform: [{ translateX: pan }] },            ]}          >            {items.map((t, i) => (              <View                key={t.value}                onLayout={onPaneLayout(i)}                accessibilityElementsHidden={i !== index}                importantForAccessibility={i === index ? 'auto' : 'no-hide-descendants'}                style={[styles.pane, { width: paneW }, contentStyle]}              >                {t.content}              </View>            ))}          </Animated.View>        ) : (          <View style={[styles.pane, contentStyle]}>{current?.content}</View>        )}      </Animated.View>    );  }   return (    <View style={style}>      <View        onLayout={(e) => setBarW(e.nativeEvent.layout.width)}        style={{ borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: hairline }}      >        <ScrollView          ref={scrollRef}          horizontal          scrollEnabled={overflow}          bounces={overflow}          showsHorizontalScrollIndicator={false}          onContentSizeChange={(w) => setContentW(w)}          contentContainerStyle={styles.scroll}        >          {row}        </ScrollView>      </View>      {panes}    </View>  );} interface TabProps {  item: TabItem;  active: boolean;  mode: 'fill' | 'auto' | 'scroll';  height: number;  fontSize: number;  tint: string;  reduce: boolean;  onPress: () => void;  onLayout: (e: LayoutChangeEvent) => void;  onLabelLayout: (e: LayoutChangeEvent) => void;} function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLayout, onLabelLayout }: TabProps) {  const { colors } = useKit();  const a = useRef(new Animated.Value(active ? 1 : 0)).current; // label colour (JS)  const press = useRef(new Animated.Value(1)).current; // squish (native)   useEffect(() => {    Animated.timing(a, { toValue: active ? 1 : 0, duration: reduce ? 0 : 180, useNativeDriver: false }).start();  }, [active, a, reduce]);   const pressTo = (to: number) =>    Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();   const fg = active ? colors.foreground : colors.mutedForeground;  const color = a.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, colors.foreground] });  const icon = typeof item.icon === 'function' ? item.icon({ active, color: fg }) : item.icon;  const slop = height < 44 ? (44 - height) / 2 : 0;   return (    <Pressable      accessibilityRole="tab"      accessibilityLabel={item.accessibilityLabel ?? (item.badge !== undefined ? `${item.label}, ${item.badge}` : item.label)}      accessibilityState={{ selected: active, disabled: !!item.disabled }}      disabled={item.disabled}      onPress={onPress}      onPressIn={() => pressTo(0.94)}      onPressOut={() => pressTo(1)}      onLayout={onLayout}      hitSlop={slop ? { top: slop, bottom: slop } : undefined}      style={[        styles.tab,        { height, paddingHorizontal: mode === 'fill' ? 8 : 14, opacity: item.disabled ? 0.4 : 1 },        mode === 'fill' && styles.fill,        mode === 'auto' && styles.grow,      ]}    >      {/* Measured: the underline spans exactly this (icon + label). */}      <Animated.View onLayout={onLabelLayout} style={[styles.labelRow, { transform: [{ scale: press }] }]}>        {icon}        <Animated.Text numberOfLines={1} style={[styles.label, { color, fontSize }]}>          {item.label}        </Animated.Text>      </Animated.View>      {item.badge !== undefined ? <TabBadge value={item.badge} active={active} tint={tint} reduce={reduce} /> : null}    </Pressable>  );} /** Count bubble: accent when its tab is active, muted otherwise. Pops when the value changes. */function TabBadge({ value, active, tint, reduce }: { value: number | string; active: boolean; tint: string; reduce: boolean }) {  const { colors, radius, dark } = useKit();  const s = useRef(new Animated.Value(1)).current;  const first = useRef(true);  useEffect(() => {    if (first.current || reduce) {      first.current = false;      return;    }    s.setValue(0.5);    Animated.spring(s, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();  }, [value, reduce, s]);  const text = typeof value === 'number' && value > 999 ? `${Math.floor(value / 1000)}k` : String(value);  return (    <Animated.View      style={[        styles.badge,        {          borderRadius: radius.capsule(18),          backgroundColor: active ? tint : alpha(colors.mutedForeground, dark ? 0.22 : 0.14),          transform: [{ scale: s }],        },      ]}    >      <Animated.Text style={[styles.badgeText, { color: active ? readableOn(tint) : colors.mutedForeground }]}>{text}</Animated.Text>    </Animated.View>  );} /** Resistance past the ends: moves less the further you pull, never more than ~half a pane. */function rubber(d: number, w: number) {  return (1 - 1 / ((d * 0.55) / Math.max(1, w) + 1)) * w * 0.5;} function useReduceMotion() {  const [reduce, setReduce] = useState(false);  useEffect(() => {    let alive = true;    AccessibilityInfo.isReduceMotionEnabled()      .then((v) => alive && setReduce(v))      .catch(() => {});    const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);    return () => {      alive = false;      sub.remove();    };  }, []);  return reduce;} const styles = StyleSheet.create({  scroll: { flexGrow: 1 },  row: { flexDirection: 'row', alignItems: 'center' },  grow: { flexGrow: 1 },  fill: { flex: 1 },  tab: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6 },  labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },  label: { fontWeight: '400', letterSpacing: -0.1 },  underline: { position: 'absolute', bottom: 0, height: UNDERLINE },  badge: { minWidth: 18, height: 18, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' },  badgeText: { fontSize: 11, fontWeight: '400', fontVariant: ['tabular-nums'] },  viewport: { overflow: 'hidden' },  pager: { flexDirection: 'row', alignItems: 'flex-start' },  pane: { paddingTop: 16 },});