Skip to content
pixelfor Expo
Ctrl K

Tabs

Four styles. One smooth transition.

Tabs / 01

Pill

Try it on your phone

Pill tabs

Scan to open Pill tabs in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

pill-tabs.tsx

import {
  useEffect,
  useRef,
  useState,
  type ReactNode,
  type ComponentRef,
} from "react"
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type LayoutRectangle,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  interpolateColor,
  useAnimatedReaction,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
const VARIANT = "pill" as string
const OVERFLOW = false
const SPRING = {
  stiffness: 245,
  damping: 36,
  mass: 1.2,
  overshootClamping: true,
}
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1)
export type TabItem = {
  value: string
  label: string
  content?: ReactNode
  disabled?: boolean
}
export interface PillTabsProps {
  items: TabItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  backgroundColor?: string
  activeColor?: string
  textColor?: string
  contentTextColor?: string
  selectedTextColor?: string
  borderColor?: string
  radius?: number
  tabHeight?: number
  horizontalPadding?: number
  fontSize?: number
  gap?: number
  contentGap?: number
  reduceMotion?: boolean
  accessibilityLabel?: string
  style?: StyleProp<ViewStyle>
  labelStyle?: StyleProp<TextStyle>
  contentStyle?: StyleProp<ViewStyle>
  testID?: string
}
function TabLabel({
  label,
  geometry,
  left,
  width,
  textColor,
  selectedTextColor,
  fontSize,
  labelStyle,
  underline,
  active,
  motion,
}: {
  label: string
  geometry?: LayoutRectangle
  left: SharedValue<number>
  width: SharedValue<number>
  textColor: string
  selectedTextColor: string
  fontSize: number
  labelStyle?: StyleProp<TextStyle>
  underline: boolean
  active: boolean
  motion: ReduceMotion
}) {
  const colorProgress = useSharedValue(active ? 1 : 0)
  useEffect(() => {
    colorProgress.set(
      withTiming(active ? 1 : 0, { duration: 180, reduceMotion: motion })
    )
  }, [active, colorProgress, motion])
  const baseStyle = useAnimatedStyle(() => ({
    color: underline
      ? interpolateColor(
          colorProgress.get(),
          [0, 1],
          [textColor, selectedTextColor]
        )
      : textColor,
  }))
  const maskStyle = useAnimatedStyle(() => {
    const origin = geometry?.x ?? 0
    const span = geometry?.width ?? 0
    const start = Math.max(0, Math.min(span, left.get() - origin))
    const end = Math.max(
      start,
      Math.min(span, left.get() + width.get() - origin)
    )
    return { width: end - start, transform: [{ translateX: start }] }
  })
  const counterStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateX: -Math.max(
          0,
          Math.min(geometry?.width ?? 0, left.get() - (geometry?.x ?? 0))
        ),
      },
    ],
  }))
  return (
    <>
      <Animated.Text
        numberOfLines={1}
        style={[{ fontSize, fontWeight: "500" }, labelStyle, baseStyle]}
      >
        {label}
      </Animated.Text>
      {!underline && (
        <Animated.View
          pointerEvents="none"
          accessibilityElementsHidden
          aria-hidden
          importantForAccessibility="no-hide-descendants"
          style={[styles.mask, maskStyle]}
        >
          <Animated.View
            style={[
              styles.maskLabel,
              { width: geometry?.width ?? 0 },
              counterStyle,
            ]}
          >
            <Text
              numberOfLines={1}
              style={[
                { color: selectedTextColor, fontSize, fontWeight: "500" },
                labelStyle,
              ]}
            >
              {label}
            </Text>
          </Animated.View>
        </Animated.View>
      )}
    </>
  )
}
function Panel({
  active,
  children,
  motion,
  reduce,
  style,
  contentGap,
  testID,
}: {
  active: boolean
  children: ReactNode
  motion: ReduceMotion
  reduce: boolean
  style?: StyleProp<ViewStyle>
  contentGap: number
  testID?: string
}) {
  const opacity = useSharedValue(0)
  const y = useSharedValue(0)
  useEffect(() => {
    if (!active) return
    opacity.set(0)
    y.set(reduce ? 0 : 4)
    opacity.set(
      withTiming(1, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
    y.set(
      withTiming(0, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
  }, [active, motion, reduce, opacity, y])
  const animation = useAnimatedStyle(() => ({
    opacity: opacity.get(),
    transform: [{ translateY: y.get() }],
  }))
  return (
    <Animated.View
      testID={testID}
      accessibilityElementsHidden={!active}
      aria-hidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      pointerEvents={active ? "auto" : "none"}
      style={[
        { marginTop: contentGap, display: active ? "flex" : "none" },
        style,
        animation,
      ]}
    >
      {children}
    </Animated.View>
  )
}
export function PillTabs({
  items,
  value,
  defaultValue,
  onValueChange,
  backgroundColor = "#1c1c1c",
  activeColor = "#0285f7",
  textColor = "#868686",
  contentTextColor = "#f1f1f1",
  selectedTextColor = "#ffffff",
  borderColor = "rgba(255,255,255,0.05)",
  radius = 999,
  tabHeight = 40,
  horizontalPadding = 14,
  fontSize = 14,
  gap = 4,
  contentGap = 16,
  reduceMotion,
  accessibilityLabel = "Tabs",
  style,
  labelStyle,
  contentStyle,
  testID,
}: PillTabsProps) {
  const [internal, setInternal] = useState(
    defaultValue ?? items.find((item) => !item.disabled)?.value ?? ""
  )
  const current = value !== undefined ? value : internal
  const [layouts, setLayouts] = useState<Record<string, LayoutRectangle>>({})
  const [viewport, setViewport] = useState(0)
  const [contentWidth, setContentWidth] = useState(0)
  const [edges, setEdges] = useState({ left: false, right: false })
  const scroll = useRef<ComponentRef<typeof Animated.ScrollView>>(null)
  const initialized = useRef(false)
  const systemReduce = useReducedMotion()
  const reduce = reduceMotion ?? systemReduce
  const motion =
    reduceMotion === undefined
      ? ReduceMotion.System
      : reduce
        ? ReduceMotion.Always
        : ReduceMotion.Never
  const left = useSharedValue(0)
  const width = useSharedValue(0)
  const indicatorHeight = useSharedValue(tabHeight)
  const indicatorTop = useSharedValue(0)
  const scrollX = useSharedValue(0)
  const underline = VARIANT === "underline"
  const inset = underline ? 0 : VARIANT === "segment" ? 2 : 4
  const target = items.some((item) => item.value === current && !item.disabled)
    ? layouts[current]
    : undefined
  const maxScroll = Math.max(0, contentWidth - viewport)
  const hasOverflow = OVERFLOW && maxScroll > 1
  useEffect(() => {
    if (!target) {
      width.set(0)
      initialized.current = false
      return
    }
    if (!initialized.current) {
      left.set(target.x)
      width.set(target.width)
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
      initialized.current = true
    } else {
      left.set(withSpring(target.x, { ...SPRING, reduceMotion: motion }))
      width.set(withSpring(target.width, { ...SPRING, reduceMotion: motion }))
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
    }
    if (!viewport) return
    const offset = Math.max(0, Math.min(maxScroll, scrollX.get()))
    const safeLeft = offset + (hasOverflow && offset > 1 ? 40 : 0)
    const safeRight =
      offset + viewport - (hasOverflow && offset < maxScroll - 1 ? 40 : 0)
    const next =
      target.width > safeRight - safeLeft
        ? target.x - (hasOverflow ? 40 : 0)
        : target.x < safeLeft
          ? offset + target.x - safeLeft
          : target.x + target.width > safeRight
            ? offset + target.x + target.width - safeRight
            : offset
    scroll.current?.scrollTo({
      x: Math.max(0, Math.min(maxScroll, next)),
      animated: !reduce,
    })
  }, [
    target,
    viewport,
    maxScroll,
    hasOverflow,
    motion,
    reduce,
    left,
    width,
    indicatorHeight,
    indicatorTop,
    scrollX,
  ])
  const scrollHandler = useAnimatedScrollHandler((event) => {
    scrollX.set(event.contentOffset.x)
  })
  useAnimatedReaction(
    () => ({
      left: hasOverflow && scrollX.get() > 1,
      right: hasOverflow && scrollX.get() < maxScroll - 1,
    }),
    (next, previous) => {
      if (
        !previous ||
        next.left !== previous.left ||
        next.right !== previous.right
      )
        scheduleOnRN(setEdges, next)
    },
    [hasOverflow, maxScroll]
  )
  const indicatorStyle = useAnimatedStyle(() => ({
    width: width.get(),
    height: underline ? 1 : indicatorHeight.get(),
    top: underline ? undefined : indicatorTop.get(),
    transform: [{ translateX: left.get() }],
  }))
  const select = (item: TabItem) => {
    if (item.disabled || item.value === current) return
    if (value === undefined) setInternal(item.value)
    onValueChange?.(item.value)
  }
  const move = (direction: number) =>
    scroll.current?.scrollTo({
      x: Math.max(
        0,
        Math.min(maxScroll, scrollX.get() + direction * viewport * 0.8)
      ),
      animated: !reduce,
    })
  return (
    <View testID={testID} style={[{ alignItems: "flex-start" }, style]}>
      <View
        onLayout={({ nativeEvent }) => setViewport(nativeEvent.layout.width)}
        style={[
          styles.viewport,
          {
            width: OVERFLOW ? "100%" : undefined,
            maxWidth: "100%",
            backgroundColor: underline ? "transparent" : backgroundColor,
            borderRadius: radius,
          },
        ]}
      >
        <Animated.ScrollView
          ref={scroll}
          horizontal
          showsHorizontalScrollIndicator={false}
          bounces={false}
          onScroll={scrollHandler}
          scrollEventThrottle={16}
          directionalLockEnabled
          onContentSizeChange={(next) => setContentWidth(next)}
          style={{ flexGrow: 0, maxWidth: "100%" }}
        >
          <View
            accessibilityRole="tablist"
            accessibilityLabel={accessibilityLabel}
            style={[
              styles.list,
              {
                padding: inset,
                gap,
                borderBottomWidth: underline ? 1 : 0,
                borderColor,
              },
            ]}
          >
            <Animated.View
              pointerEvents="none"
              accessible={false}
              style={[
                styles.indicator,
                {
                  backgroundColor: activeColor,
                  borderRadius: underline ? 0 : radius,
                  bottom: underline ? -1 : undefined,
                },
                indicatorStyle,
              ]}
            />
            {items.map((item) => (
              <Pressable
                key={item.value}
                testID={testID ? `${testID}-${item.value}` : undefined}
                accessibilityRole="tab"
                accessibilityLabel={item.label}
                accessibilityState={{
                  selected: item.value === current,
                  disabled: !!item.disabled,
                }}
                aria-selected={item.value === current}
                aria-disabled={!!item.disabled}
                disabled={item.disabled}
                onPress={() => select(item)}
                hitSlop={{ top: 4, bottom: 4 }}
                onLayout={({ nativeEvent }) => {
                  const next = nativeEvent.layout
                  setLayouts((previous) => {
                    const old = previous[item.value]
                    return old &&
                      old.x === next.x &&
                      old.y === next.y &&
                      old.width === next.width &&
                      old.height === next.height
                      ? previous
                      : { ...previous, [item.value]: next }
                  })
                }}
                style={({ pressed }) => [
                  styles.trigger,
                  {
                    minHeight: Math.max(40, tabHeight),
                    paddingHorizontal: horizontalPadding,
                    paddingVertical: 6,
                    opacity: item.disabled ? 0.4 : pressed ? 0.8 : 1,
                  },
                ]}
              >
                <TabLabel
                  label={item.label}
                  geometry={layouts[item.value]}
                  left={left}
                  width={width}
                  {...{
                    textColor,
                    selectedTextColor,
                    fontSize,
                    labelStyle,
                    underline,
                    motion,
                  }}
                  active={item.value === current}
                />
              </Pressable>
            ))}
          </View>
        </Animated.ScrollView>
      </View>
      {items.map(
        (item) =>
          item.content != null && (
            <Panel
              key={item.value}
              testID={testID ? `${testID}-panel-${item.value}` : undefined}
              active={item.value === current && !item.disabled}
              motion={motion}
              reduce={reduce}
              style={contentStyle}
              contentGap={contentGap}
            >
              {typeof item.content === "string" ||
              typeof item.content === "number" ? (
                <Text style={{ color: contentTextColor, fontSize, lineHeight: 22 }}>
                  {item.content}
                </Text>
              ) : (
                item.content
              )}
            </Panel>
          )
      )}
    </View>
  )
}
const styles = StyleSheet.create({
  viewport: { overflow: "hidden", flexDirection: "row", direction: "ltr" },
  list: { position: "relative", flexDirection: "row", alignItems: "center" },
  indicator: { position: "absolute", left: 0 },
  trigger: { justifyContent: "center", alignItems: "center", flexShrink: 0 },
  mask: {
    position: "absolute",
    left: 0,
    top: 0,
    bottom: 0,
    overflow: "hidden",
  },
  maskLabel: { height: "100%", justifyContent: "center", alignItems: "center" },
  edge: {
    position: "absolute",
    top: 0,
    bottom: 0,
    width: 48,
    justifyContent: "center",
    alignItems: "center",
  },
  arrow: { width: 8, height: 8, borderRightWidth: 1.5, borderBottomWidth: 1.5 },
})

Tabs / 02

Overflow

Try it on your phone

Overflow tabs

Scan to open Overflow tabs in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

overflow-tabs.tsx

import {
  useEffect,
  useRef,
  useState,
  type ReactNode,
  type ComponentRef,
} from "react"
import {
  processColor,
  Pressable,
  StyleSheet,
  Text,
  View,
  type LayoutRectangle,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  interpolateColor,
  useAnimatedReaction,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import { LinearGradient } from "expo-linear-gradient"
function transparent(color: string) {
  const resolved = processColor(color)
  if (typeof resolved !== "number") return "transparent"
  return `rgba(${(resolved >>> 16) & 255},${(resolved >>> 8) & 255},${resolved & 255},0)`
}
const VARIANT = "pill" as string
const OVERFLOW = true
const SPRING = {
  stiffness: 245,
  damping: 36,
  mass: 1.2,
  overshootClamping: true,
}
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1)
export type TabItem = {
  value: string
  label: string
  content?: ReactNode
  disabled?: boolean
}
export interface OverflowTabsProps {
  items: TabItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  backgroundColor?: string
  activeColor?: string
  textColor?: string
  contentTextColor?: string
  selectedTextColor?: string
  borderColor?: string
  radius?: number
  tabHeight?: number
  horizontalPadding?: number
  fontSize?: number
  gap?: number
  contentGap?: number
  reduceMotion?: boolean
  accessibilityLabel?: string
  style?: StyleProp<ViewStyle>
  labelStyle?: StyleProp<TextStyle>
  contentStyle?: StyleProp<ViewStyle>
  testID?: string
}
function TabLabel({
  label,
  geometry,
  left,
  width,
  textColor,
  selectedTextColor,
  fontSize,
  labelStyle,
  underline,
  active,
  motion,
}: {
  label: string
  geometry?: LayoutRectangle
  left: SharedValue<number>
  width: SharedValue<number>
  textColor: string
  selectedTextColor: string
  fontSize: number
  labelStyle?: StyleProp<TextStyle>
  underline: boolean
  active: boolean
  motion: ReduceMotion
}) {
  const colorProgress = useSharedValue(active ? 1 : 0)
  useEffect(() => {
    colorProgress.set(
      withTiming(active ? 1 : 0, { duration: 180, reduceMotion: motion })
    )
  }, [active, colorProgress, motion])
  const baseStyle = useAnimatedStyle(() => ({
    color: underline
      ? interpolateColor(
          colorProgress.get(),
          [0, 1],
          [textColor, selectedTextColor]
        )
      : textColor,
  }))
  const maskStyle = useAnimatedStyle(() => {
    const origin = geometry?.x ?? 0
    const span = geometry?.width ?? 0
    const start = Math.max(0, Math.min(span, left.get() - origin))
    const end = Math.max(
      start,
      Math.min(span, left.get() + width.get() - origin)
    )
    return { width: end - start, transform: [{ translateX: start }] }
  })
  const counterStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateX: -Math.max(
          0,
          Math.min(geometry?.width ?? 0, left.get() - (geometry?.x ?? 0))
        ),
      },
    ],
  }))
  return (
    <>
      <Animated.Text
        numberOfLines={1}
        style={[{ fontSize, fontWeight: "500" }, labelStyle, baseStyle]}
      >
        {label}
      </Animated.Text>
      {!underline && (
        <Animated.View
          pointerEvents="none"
          accessibilityElementsHidden
          aria-hidden
          importantForAccessibility="no-hide-descendants"
          style={[styles.mask, maskStyle]}
        >
          <Animated.View
            style={[
              styles.maskLabel,
              { width: geometry?.width ?? 0 },
              counterStyle,
            ]}
          >
            <Text
              numberOfLines={1}
              style={[
                { color: selectedTextColor, fontSize, fontWeight: "500" },
                labelStyle,
              ]}
            >
              {label}
            </Text>
          </Animated.View>
        </Animated.View>
      )}
    </>
  )
}
function Panel({
  active,
  children,
  motion,
  reduce,
  style,
  contentGap,
  testID,
}: {
  active: boolean
  children: ReactNode
  motion: ReduceMotion
  reduce: boolean
  style?: StyleProp<ViewStyle>
  contentGap: number
  testID?: string
}) {
  const opacity = useSharedValue(0)
  const y = useSharedValue(0)
  useEffect(() => {
    if (!active) return
    opacity.set(0)
    y.set(reduce ? 0 : 4)
    opacity.set(
      withTiming(1, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
    y.set(
      withTiming(0, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
  }, [active, motion, reduce, opacity, y])
  const animation = useAnimatedStyle(() => ({
    opacity: opacity.get(),
    transform: [{ translateY: y.get() }],
  }))
  return (
    <Animated.View
      testID={testID}
      accessibilityElementsHidden={!active}
      aria-hidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      pointerEvents={active ? "auto" : "none"}
      style={[
        { marginTop: contentGap, display: active ? "flex" : "none" },
        style,
        animation,
      ]}
    >
      {children}
    </Animated.View>
  )
}
export function OverflowTabs({
  items,
  value,
  defaultValue,
  onValueChange,
  backgroundColor = "#1c1c1c",
  activeColor = "#0285f7",
  textColor = "#868686",
  contentTextColor = "#f1f1f1",
  selectedTextColor = "#ffffff",
  borderColor = "rgba(255,255,255,0.05)",
  radius = 999,
  tabHeight = 40,
  horizontalPadding = 14,
  fontSize = 14,
  gap = 4,
  contentGap = 16,
  reduceMotion,
  accessibilityLabel = "Tabs",
  style,
  labelStyle,
  contentStyle,
  testID,
}: OverflowTabsProps) {
  const [internal, setInternal] = useState(
    defaultValue ?? items.find((item) => !item.disabled)?.value ?? ""
  )
  const current = value !== undefined ? value : internal
  const [layouts, setLayouts] = useState<Record<string, LayoutRectangle>>({})
  const [viewport, setViewport] = useState(0)
  const [contentWidth, setContentWidth] = useState(0)
  const [edges, setEdges] = useState({ left: false, right: false })
  const scroll = useRef<ComponentRef<typeof Animated.ScrollView>>(null)
  const initialized = useRef(false)
  const systemReduce = useReducedMotion()
  const reduce = reduceMotion ?? systemReduce
  const motion =
    reduceMotion === undefined
      ? ReduceMotion.System
      : reduce
        ? ReduceMotion.Always
        : ReduceMotion.Never
  const left = useSharedValue(0)
  const width = useSharedValue(0)
  const indicatorHeight = useSharedValue(tabHeight)
  const indicatorTop = useSharedValue(0)
  const scrollX = useSharedValue(0)
  const underline = VARIANT === "underline"
  const inset = underline ? 0 : VARIANT === "segment" ? 2 : 4
  const target = items.some((item) => item.value === current && !item.disabled)
    ? layouts[current]
    : undefined
  const maxScroll = Math.max(0, contentWidth - viewport)
  const hasOverflow = OVERFLOW && maxScroll > 1
  useEffect(() => {
    if (!target) {
      width.set(0)
      initialized.current = false
      return
    }
    if (!initialized.current) {
      left.set(target.x)
      width.set(target.width)
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
      initialized.current = true
    } else {
      left.set(withSpring(target.x, { ...SPRING, reduceMotion: motion }))
      width.set(withSpring(target.width, { ...SPRING, reduceMotion: motion }))
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
    }
    if (!viewport) return
    const offset = Math.max(0, Math.min(maxScroll, scrollX.get()))
    const safeLeft = offset + (hasOverflow && offset > 1 ? 40 : 0)
    const safeRight =
      offset + viewport - (hasOverflow && offset < maxScroll - 1 ? 40 : 0)
    const next =
      target.width > safeRight - safeLeft
        ? target.x - (hasOverflow ? 40 : 0)
        : target.x < safeLeft
          ? offset + target.x - safeLeft
          : target.x + target.width > safeRight
            ? offset + target.x + target.width - safeRight
            : offset
    scroll.current?.scrollTo({
      x: Math.max(0, Math.min(maxScroll, next)),
      animated: !reduce,
    })
  }, [
    target,
    viewport,
    maxScroll,
    hasOverflow,
    motion,
    reduce,
    left,
    width,
    indicatorHeight,
    indicatorTop,
    scrollX,
  ])
  const scrollHandler = useAnimatedScrollHandler((event) => {
    scrollX.set(event.contentOffset.x)
  })
  useAnimatedReaction(
    () => ({
      left: hasOverflow && scrollX.get() > 1,
      right: hasOverflow && scrollX.get() < maxScroll - 1,
    }),
    (next, previous) => {
      if (
        !previous ||
        next.left !== previous.left ||
        next.right !== previous.right
      )
        scheduleOnRN(setEdges, next)
    },
    [hasOverflow, maxScroll]
  )
  const indicatorStyle = useAnimatedStyle(() => ({
    width: width.get(),
    height: underline ? 1 : indicatorHeight.get(),
    top: underline ? undefined : indicatorTop.get(),
    transform: [{ translateX: left.get() }],
  }))
  const select = (item: TabItem) => {
    if (item.disabled || item.value === current) return
    if (value === undefined) setInternal(item.value)
    onValueChange?.(item.value)
  }
  const move = (direction: number) =>
    scroll.current?.scrollTo({
      x: Math.max(
        0,
        Math.min(maxScroll, scrollX.get() + direction * viewport * 0.8)
      ),
      animated: !reduce,
    })
  return (
    <View testID={testID} style={[{ alignItems: "flex-start" }, style]}>
      <View
        onLayout={({ nativeEvent }) => setViewport(nativeEvent.layout.width)}
        style={[
          styles.viewport,
          {
            width: OVERFLOW ? "100%" : undefined,
            maxWidth: "100%",
            backgroundColor: underline ? "transparent" : backgroundColor,
            borderRadius: radius,
          },
        ]}
      >
        <Animated.ScrollView
          ref={scroll}
          horizontal
          showsHorizontalScrollIndicator={false}
          bounces={false}
          onScroll={scrollHandler}
          scrollEventThrottle={16}
          directionalLockEnabled
          onContentSizeChange={(next) => setContentWidth(next)}
          style={{ flexGrow: 0, maxWidth: "100%" }}
        >
          <View
            accessibilityRole="tablist"
            accessibilityLabel={accessibilityLabel}
            style={[
              styles.list,
              {
                padding: inset,
                gap,
                borderBottomWidth: underline ? 1 : 0,
                borderColor,
              },
            ]}
          >
            <Animated.View
              pointerEvents="none"
              accessible={false}
              style={[
                styles.indicator,
                {
                  backgroundColor: activeColor,
                  borderRadius: underline ? 0 : radius,
                  bottom: underline ? -1 : undefined,
                },
                indicatorStyle,
              ]}
            />
            {items.map((item) => (
              <Pressable
                key={item.value}
                testID={testID ? `${testID}-${item.value}` : undefined}
                accessibilityRole="tab"
                accessibilityLabel={item.label}
                accessibilityState={{
                  selected: item.value === current,
                  disabled: !!item.disabled,
                }}
                aria-selected={item.value === current}
                aria-disabled={!!item.disabled}
                disabled={item.disabled}
                onPress={() => select(item)}
                hitSlop={{ top: 4, bottom: 4 }}
                onLayout={({ nativeEvent }) => {
                  const next = nativeEvent.layout
                  setLayouts((previous) => {
                    const old = previous[item.value]
                    return old &&
                      old.x === next.x &&
                      old.y === next.y &&
                      old.width === next.width &&
                      old.height === next.height
                      ? previous
                      : { ...previous, [item.value]: next }
                  })
                }}
                style={({ pressed }) => [
                  styles.trigger,
                  {
                    minHeight: Math.max(40, tabHeight),
                    paddingHorizontal: horizontalPadding,
                    paddingVertical: 6,
                    opacity: item.disabled ? 0.4 : pressed ? 0.8 : 1,
                  },
                ]}
              >
                <TabLabel
                  label={item.label}
                  geometry={layouts[item.value]}
                  left={left}
                  width={width}
                  {...{
                    textColor,
                    selectedTextColor,
                    fontSize,
                    labelStyle,
                    underline,
                    motion,
                  }}
                  active={item.value === current}
                />
              </Pressable>
            ))}
          </View>
        </Animated.ScrollView>
        {hasOverflow && (
          <>
            {edges.left && (
              <View pointerEvents="box-none" style={[styles.edge, { left: 0 }]}>
                <LinearGradient
                  pointerEvents="none"
                  colors={[backgroundColor, transparent(backgroundColor)]}
                  start={{ x: 0, y: 0 }}
                  end={{ x: 1, y: 0 }}
                  style={StyleSheet.absoluteFill}
                />
                <Pressable
                  accessibilityRole="button"
                  accessibilityLabel="Scroll tabs left"
                  onPress={() => move(-1)}
                  style={[styles.edge, { left: 0 }]}
                >
                  <View
                    style={[
                      styles.arrow,
                      {
                        borderColor: selectedTextColor,
                        transform: [{ rotate: "135deg" }],
                      },
                    ]}
                  />
                </Pressable>
              </View>
            )}
            {edges.right && (
              <View
                pointerEvents="box-none"
                style={[styles.edge, { right: 0 }]}
              >
                <LinearGradient
                  pointerEvents="none"
                  colors={[transparent(backgroundColor), backgroundColor]}
                  start={{ x: 0, y: 0 }}
                  end={{ x: 1, y: 0 }}
                  style={StyleSheet.absoluteFill}
                />
                <Pressable
                  accessibilityRole="button"
                  accessibilityLabel="Scroll tabs right"
                  onPress={() => move(1)}
                  style={[styles.edge, { right: 0 }]}
                >
                  <View
                    style={[
                      styles.arrow,
                      {
                        borderColor: selectedTextColor,
                        transform: [{ rotate: "-45deg" }],
                      },
                    ]}
                  />
                </Pressable>
              </View>
            )}
          </>
        )}
      </View>
      {items.map(
        (item) =>
          item.content != null && (
            <Panel
              key={item.value}
              testID={testID ? `${testID}-panel-${item.value}` : undefined}
              active={item.value === current && !item.disabled}
              motion={motion}
              reduce={reduce}
              style={contentStyle}
              contentGap={contentGap}
            >
              {typeof item.content === "string" ||
              typeof item.content === "number" ? (
                <Text style={{ color: contentTextColor, fontSize, lineHeight: 22 }}>
                  {item.content}
                </Text>
              ) : (
                item.content
              )}
            </Panel>
          )
      )}
    </View>
  )
}
const styles = StyleSheet.create({
  viewport: { overflow: "hidden", flexDirection: "row", direction: "ltr" },
  list: { position: "relative", flexDirection: "row", alignItems: "center" },
  indicator: { position: "absolute", left: 0 },
  trigger: { justifyContent: "center", alignItems: "center", flexShrink: 0 },
  mask: {
    position: "absolute",
    left: 0,
    top: 0,
    bottom: 0,
    overflow: "hidden",
  },
  maskLabel: { height: "100%", justifyContent: "center", alignItems: "center" },
  edge: {
    position: "absolute",
    top: 0,
    bottom: 0,
    width: 48,
    justifyContent: "center",
    alignItems: "center",
  },
  arrow: { width: 8, height: 8, borderRightWidth: 1.5, borderBottomWidth: 1.5 },
})

Tabs / 03

Segment

Try it on your phone

Segment tabs

Scan to open Segment tabs in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

segment-tabs.tsx

import {
  useEffect,
  useRef,
  useState,
  type ReactNode,
  type ComponentRef,
} from "react"
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type LayoutRectangle,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  interpolateColor,
  useAnimatedReaction,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
const VARIANT = "segment" as string
const OVERFLOW = false
const SPRING = {
  stiffness: 245,
  damping: 36,
  mass: 1.2,
  overshootClamping: true,
}
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1)
export type TabItem = {
  value: string
  label: string
  content?: ReactNode
  disabled?: boolean
}
export interface SegmentTabsProps {
  items: TabItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  backgroundColor?: string
  activeColor?: string
  textColor?: string
  contentTextColor?: string
  selectedTextColor?: string
  borderColor?: string
  radius?: number
  tabHeight?: number
  horizontalPadding?: number
  fontSize?: number
  gap?: number
  contentGap?: number
  reduceMotion?: boolean
  accessibilityLabel?: string
  style?: StyleProp<ViewStyle>
  labelStyle?: StyleProp<TextStyle>
  contentStyle?: StyleProp<ViewStyle>
  testID?: string
}
function TabLabel({
  label,
  geometry,
  left,
  width,
  textColor,
  selectedTextColor,
  fontSize,
  labelStyle,
  underline,
  active,
  motion,
}: {
  label: string
  geometry?: LayoutRectangle
  left: SharedValue<number>
  width: SharedValue<number>
  textColor: string
  selectedTextColor: string
  fontSize: number
  labelStyle?: StyleProp<TextStyle>
  underline: boolean
  active: boolean
  motion: ReduceMotion
}) {
  const colorProgress = useSharedValue(active ? 1 : 0)
  useEffect(() => {
    colorProgress.set(
      withTiming(active ? 1 : 0, { duration: 180, reduceMotion: motion })
    )
  }, [active, colorProgress, motion])
  const baseStyle = useAnimatedStyle(() => ({
    color: underline
      ? interpolateColor(
          colorProgress.get(),
          [0, 1],
          [textColor, selectedTextColor]
        )
      : textColor,
  }))
  const maskStyle = useAnimatedStyle(() => {
    const origin = geometry?.x ?? 0
    const span = geometry?.width ?? 0
    const start = Math.max(0, Math.min(span, left.get() - origin))
    const end = Math.max(
      start,
      Math.min(span, left.get() + width.get() - origin)
    )
    return { width: end - start, transform: [{ translateX: start }] }
  })
  const counterStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateX: -Math.max(
          0,
          Math.min(geometry?.width ?? 0, left.get() - (geometry?.x ?? 0))
        ),
      },
    ],
  }))
  return (
    <>
      <Animated.Text
        numberOfLines={1}
        style={[{ fontSize, fontWeight: "500" }, labelStyle, baseStyle]}
      >
        {label}
      </Animated.Text>
      {!underline && (
        <Animated.View
          pointerEvents="none"
          accessibilityElementsHidden
          aria-hidden
          importantForAccessibility="no-hide-descendants"
          style={[styles.mask, maskStyle]}
        >
          <Animated.View
            style={[
              styles.maskLabel,
              { width: geometry?.width ?? 0 },
              counterStyle,
            ]}
          >
            <Text
              numberOfLines={1}
              style={[
                { color: selectedTextColor, fontSize, fontWeight: "500" },
                labelStyle,
              ]}
            >
              {label}
            </Text>
          </Animated.View>
        </Animated.View>
      )}
    </>
  )
}
function Panel({
  active,
  children,
  motion,
  reduce,
  style,
  contentGap,
  testID,
}: {
  active: boolean
  children: ReactNode
  motion: ReduceMotion
  reduce: boolean
  style?: StyleProp<ViewStyle>
  contentGap: number
  testID?: string
}) {
  const opacity = useSharedValue(0)
  const y = useSharedValue(0)
  useEffect(() => {
    if (!active) return
    opacity.set(0)
    y.set(reduce ? 0 : 4)
    opacity.set(
      withTiming(1, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
    y.set(
      withTiming(0, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
  }, [active, motion, reduce, opacity, y])
  const animation = useAnimatedStyle(() => ({
    opacity: opacity.get(),
    transform: [{ translateY: y.get() }],
  }))
  return (
    <Animated.View
      testID={testID}
      accessibilityElementsHidden={!active}
      aria-hidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      pointerEvents={active ? "auto" : "none"}
      style={[
        { marginTop: contentGap, display: active ? "flex" : "none" },
        style,
        animation,
      ]}
    >
      {children}
    </Animated.View>
  )
}
export function SegmentTabs({
  items,
  value,
  defaultValue,
  onValueChange,
  backgroundColor = "#1c1c1c",
  activeColor = "#0285f7",
  textColor = "#868686",
  contentTextColor = "#f1f1f1",
  selectedTextColor = "#ffffff",
  borderColor = "rgba(255,255,255,0.05)",
  radius = 8,
  tabHeight = 40,
  horizontalPadding = 14,
  fontSize = 14,
  gap = 0,
  contentGap = 16,
  reduceMotion,
  accessibilityLabel = "Tabs",
  style,
  labelStyle,
  contentStyle,
  testID,
}: SegmentTabsProps) {
  const [internal, setInternal] = useState(
    defaultValue ?? items.find((item) => !item.disabled)?.value ?? ""
  )
  const current = value !== undefined ? value : internal
  const [layouts, setLayouts] = useState<Record<string, LayoutRectangle>>({})
  const [viewport, setViewport] = useState(0)
  const [contentWidth, setContentWidth] = useState(0)
  const [edges, setEdges] = useState({ left: false, right: false })
  const scroll = useRef<ComponentRef<typeof Animated.ScrollView>>(null)
  const initialized = useRef(false)
  const systemReduce = useReducedMotion()
  const reduce = reduceMotion ?? systemReduce
  const motion =
    reduceMotion === undefined
      ? ReduceMotion.System
      : reduce
        ? ReduceMotion.Always
        : ReduceMotion.Never
  const left = useSharedValue(0)
  const width = useSharedValue(0)
  const indicatorHeight = useSharedValue(tabHeight)
  const indicatorTop = useSharedValue(0)
  const scrollX = useSharedValue(0)
  const underline = VARIANT === "underline"
  const inset = underline ? 0 : VARIANT === "segment" ? 2 : 4
  const target = items.some((item) => item.value === current && !item.disabled)
    ? layouts[current]
    : undefined
  const maxScroll = Math.max(0, contentWidth - viewport)
  const hasOverflow = OVERFLOW && maxScroll > 1
  useEffect(() => {
    if (!target) {
      width.set(0)
      initialized.current = false
      return
    }
    if (!initialized.current) {
      left.set(target.x)
      width.set(target.width)
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
      initialized.current = true
    } else {
      left.set(withSpring(target.x, { ...SPRING, reduceMotion: motion }))
      width.set(withSpring(target.width, { ...SPRING, reduceMotion: motion }))
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
    }
    if (!viewport) return
    const offset = Math.max(0, Math.min(maxScroll, scrollX.get()))
    const safeLeft = offset + (hasOverflow && offset > 1 ? 40 : 0)
    const safeRight =
      offset + viewport - (hasOverflow && offset < maxScroll - 1 ? 40 : 0)
    const next =
      target.width > safeRight - safeLeft
        ? target.x - (hasOverflow ? 40 : 0)
        : target.x < safeLeft
          ? offset + target.x - safeLeft
          : target.x + target.width > safeRight
            ? offset + target.x + target.width - safeRight
            : offset
    scroll.current?.scrollTo({
      x: Math.max(0, Math.min(maxScroll, next)),
      animated: !reduce,
    })
  }, [
    target,
    viewport,
    maxScroll,
    hasOverflow,
    motion,
    reduce,
    left,
    width,
    indicatorHeight,
    indicatorTop,
    scrollX,
  ])
  const scrollHandler = useAnimatedScrollHandler((event) => {
    scrollX.set(event.contentOffset.x)
  })
  useAnimatedReaction(
    () => ({
      left: hasOverflow && scrollX.get() > 1,
      right: hasOverflow && scrollX.get() < maxScroll - 1,
    }),
    (next, previous) => {
      if (
        !previous ||
        next.left !== previous.left ||
        next.right !== previous.right
      )
        scheduleOnRN(setEdges, next)
    },
    [hasOverflow, maxScroll]
  )
  const indicatorStyle = useAnimatedStyle(() => ({
    width: width.get(),
    height: underline ? 1 : indicatorHeight.get(),
    top: underline ? undefined : indicatorTop.get(),
    transform: [{ translateX: left.get() }],
  }))
  const select = (item: TabItem) => {
    if (item.disabled || item.value === current) return
    if (value === undefined) setInternal(item.value)
    onValueChange?.(item.value)
  }
  const move = (direction: number) =>
    scroll.current?.scrollTo({
      x: Math.max(
        0,
        Math.min(maxScroll, scrollX.get() + direction * viewport * 0.8)
      ),
      animated: !reduce,
    })
  return (
    <View testID={testID} style={[{ alignItems: "flex-start" }, style]}>
      <View
        onLayout={({ nativeEvent }) => setViewport(nativeEvent.layout.width)}
        style={[
          styles.viewport,
          {
            width: OVERFLOW ? "100%" : undefined,
            maxWidth: "100%",
            backgroundColor: underline ? "transparent" : backgroundColor,
            borderRadius: radius,
          },
        ]}
      >
        <Animated.ScrollView
          ref={scroll}
          horizontal
          showsHorizontalScrollIndicator={false}
          bounces={false}
          onScroll={scrollHandler}
          scrollEventThrottle={16}
          directionalLockEnabled
          onContentSizeChange={(next) => setContentWidth(next)}
          style={{ flexGrow: 0, maxWidth: "100%" }}
        >
          <View
            accessibilityRole="tablist"
            accessibilityLabel={accessibilityLabel}
            style={[
              styles.list,
              {
                padding: inset,
                gap,
                borderBottomWidth: underline ? 1 : 0,
                borderColor,
              },
            ]}
          >
            <Animated.View
              pointerEvents="none"
              accessible={false}
              style={[
                styles.indicator,
                {
                  backgroundColor: activeColor,
                  borderRadius: underline ? 0 : radius,
                  bottom: underline ? -1 : undefined,
                },
                indicatorStyle,
              ]}
            />
            {items.map((item) => (
              <Pressable
                key={item.value}
                testID={testID ? `${testID}-${item.value}` : undefined}
                accessibilityRole="tab"
                accessibilityLabel={item.label}
                accessibilityState={{
                  selected: item.value === current,
                  disabled: !!item.disabled,
                }}
                aria-selected={item.value === current}
                aria-disabled={!!item.disabled}
                disabled={item.disabled}
                onPress={() => select(item)}
                hitSlop={{ top: 4, bottom: 4 }}
                onLayout={({ nativeEvent }) => {
                  const next = nativeEvent.layout
                  setLayouts((previous) => {
                    const old = previous[item.value]
                    return old &&
                      old.x === next.x &&
                      old.y === next.y &&
                      old.width === next.width &&
                      old.height === next.height
                      ? previous
                      : { ...previous, [item.value]: next }
                  })
                }}
                style={({ pressed }) => [
                  styles.trigger,
                  {
                    minHeight: Math.max(40, tabHeight),
                    paddingHorizontal: horizontalPadding,
                    paddingVertical: 6,
                    opacity: item.disabled ? 0.4 : pressed ? 0.8 : 1,
                  },
                ]}
              >
                <TabLabel
                  label={item.label}
                  geometry={layouts[item.value]}
                  left={left}
                  width={width}
                  {...{
                    textColor,
                    selectedTextColor,
                    fontSize,
                    labelStyle,
                    underline,
                    motion,
                  }}
                  active={item.value === current}
                />
              </Pressable>
            ))}
          </View>
        </Animated.ScrollView>
      </View>
      {items.map(
        (item) =>
          item.content != null && (
            <Panel
              key={item.value}
              testID={testID ? `${testID}-panel-${item.value}` : undefined}
              active={item.value === current && !item.disabled}
              motion={motion}
              reduce={reduce}
              style={contentStyle}
              contentGap={contentGap}
            >
              {typeof item.content === "string" ||
              typeof item.content === "number" ? (
                <Text style={{ color: contentTextColor, fontSize, lineHeight: 22 }}>
                  {item.content}
                </Text>
              ) : (
                item.content
              )}
            </Panel>
          )
      )}
    </View>
  )
}
const styles = StyleSheet.create({
  viewport: { overflow: "hidden", flexDirection: "row", direction: "ltr" },
  list: { position: "relative", flexDirection: "row", alignItems: "center" },
  indicator: { position: "absolute", left: 0 },
  trigger: { justifyContent: "center", alignItems: "center", flexShrink: 0 },
  mask: {
    position: "absolute",
    left: 0,
    top: 0,
    bottom: 0,
    overflow: "hidden",
  },
  maskLabel: { height: "100%", justifyContent: "center", alignItems: "center" },
  edge: {
    position: "absolute",
    top: 0,
    bottom: 0,
    width: 48,
    justifyContent: "center",
    alignItems: "center",
  },
  arrow: { width: 8, height: 8, borderRightWidth: 1.5, borderBottomWidth: 1.5 },
})

Tabs / 04

Underline

Try it on your phone

Underline tabs

Scan to open Underline tabs in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

underline-tabs.tsx

import {
  useEffect,
  useRef,
  useState,
  type ReactNode,
  type ComponentRef,
} from "react"
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type LayoutRectangle,
  type StyleProp,
  type TextStyle,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  interpolateColor,
  useAnimatedReaction,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
const VARIANT = "underline" as string
const OVERFLOW = false
const SPRING = {
  stiffness: 245,
  damping: 36,
  mass: 1.2,
  overshootClamping: true,
}
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1)
export type TabItem = {
  value: string
  label: string
  content?: ReactNode
  disabled?: boolean
}
export interface UnderlineTabsProps {
  items: TabItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  backgroundColor?: string
  activeColor?: string
  textColor?: string
  contentTextColor?: string
  selectedTextColor?: string
  borderColor?: string
  radius?: number
  tabHeight?: number
  horizontalPadding?: number
  fontSize?: number
  gap?: number
  contentGap?: number
  reduceMotion?: boolean
  accessibilityLabel?: string
  style?: StyleProp<ViewStyle>
  labelStyle?: StyleProp<TextStyle>
  contentStyle?: StyleProp<ViewStyle>
  testID?: string
}
function TabLabel({
  label,
  geometry,
  left,
  width,
  textColor,
  selectedTextColor,
  fontSize,
  labelStyle,
  underline,
  active,
  motion,
}: {
  label: string
  geometry?: LayoutRectangle
  left: SharedValue<number>
  width: SharedValue<number>
  textColor: string
  selectedTextColor: string
  fontSize: number
  labelStyle?: StyleProp<TextStyle>
  underline: boolean
  active: boolean
  motion: ReduceMotion
}) {
  const colorProgress = useSharedValue(active ? 1 : 0)
  useEffect(() => {
    colorProgress.set(
      withTiming(active ? 1 : 0, { duration: 180, reduceMotion: motion })
    )
  }, [active, colorProgress, motion])
  const baseStyle = useAnimatedStyle(() => ({
    color: underline
      ? interpolateColor(
          colorProgress.get(),
          [0, 1],
          [textColor, selectedTextColor]
        )
      : textColor,
  }))
  const maskStyle = useAnimatedStyle(() => {
    const origin = geometry?.x ?? 0
    const span = geometry?.width ?? 0
    const start = Math.max(0, Math.min(span, left.get() - origin))
    const end = Math.max(
      start,
      Math.min(span, left.get() + width.get() - origin)
    )
    return { width: end - start, transform: [{ translateX: start }] }
  })
  const counterStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateX: -Math.max(
          0,
          Math.min(geometry?.width ?? 0, left.get() - (geometry?.x ?? 0))
        ),
      },
    ],
  }))
  return (
    <>
      <Animated.Text
        numberOfLines={1}
        style={[{ fontSize, fontWeight: "500" }, labelStyle, baseStyle]}
      >
        {label}
      </Animated.Text>
      {!underline && (
        <Animated.View
          pointerEvents="none"
          accessibilityElementsHidden
          aria-hidden
          importantForAccessibility="no-hide-descendants"
          style={[styles.mask, maskStyle]}
        >
          <Animated.View
            style={[
              styles.maskLabel,
              { width: geometry?.width ?? 0 },
              counterStyle,
            ]}
          >
            <Text
              numberOfLines={1}
              style={[
                { color: selectedTextColor, fontSize, fontWeight: "500" },
                labelStyle,
              ]}
            >
              {label}
            </Text>
          </Animated.View>
        </Animated.View>
      )}
    </>
  )
}
function Panel({
  active,
  children,
  motion,
  reduce,
  style,
  contentGap,
  testID,
}: {
  active: boolean
  children: ReactNode
  motion: ReduceMotion
  reduce: boolean
  style?: StyleProp<ViewStyle>
  contentGap: number
  testID?: string
}) {
  const opacity = useSharedValue(0)
  const y = useSharedValue(0)
  useEffect(() => {
    if (!active) return
    opacity.set(0)
    y.set(reduce ? 0 : 4)
    opacity.set(
      withTiming(1, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
    y.set(
      withTiming(0, { duration: 180, easing: EASE_OUT, reduceMotion: motion })
    )
  }, [active, motion, reduce, opacity, y])
  const animation = useAnimatedStyle(() => ({
    opacity: opacity.get(),
    transform: [{ translateY: y.get() }],
  }))
  return (
    <Animated.View
      testID={testID}
      accessibilityElementsHidden={!active}
      aria-hidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      pointerEvents={active ? "auto" : "none"}
      style={[
        { marginTop: contentGap, display: active ? "flex" : "none" },
        style,
        animation,
      ]}
    >
      {children}
    </Animated.View>
  )
}
export function UnderlineTabs({
  items,
  value,
  defaultValue,
  onValueChange,
  backgroundColor = "#1c1c1c",
  activeColor = "#0285f7",
  textColor = "#868686",
  contentTextColor = "#f1f1f1",
  selectedTextColor = "#ffffff",
  borderColor = "rgba(255,255,255,0.05)",
  radius = 0,
  tabHeight = 40,
  horizontalPadding = 14,
  fontSize = 14,
  gap = 4,
  contentGap = 16,
  reduceMotion,
  accessibilityLabel = "Tabs",
  style,
  labelStyle,
  contentStyle,
  testID,
}: UnderlineTabsProps) {
  const [internal, setInternal] = useState(
    defaultValue ?? items.find((item) => !item.disabled)?.value ?? ""
  )
  const current = value !== undefined ? value : internal
  const [layouts, setLayouts] = useState<Record<string, LayoutRectangle>>({})
  const [viewport, setViewport] = useState(0)
  const [contentWidth, setContentWidth] = useState(0)
  const [edges, setEdges] = useState({ left: false, right: false })
  const scroll = useRef<ComponentRef<typeof Animated.ScrollView>>(null)
  const initialized = useRef(false)
  const systemReduce = useReducedMotion()
  const reduce = reduceMotion ?? systemReduce
  const motion =
    reduceMotion === undefined
      ? ReduceMotion.System
      : reduce
        ? ReduceMotion.Always
        : ReduceMotion.Never
  const left = useSharedValue(0)
  const width = useSharedValue(0)
  const indicatorHeight = useSharedValue(tabHeight)
  const indicatorTop = useSharedValue(0)
  const scrollX = useSharedValue(0)
  const underline = VARIANT === "underline"
  const inset = underline ? 0 : VARIANT === "segment" ? 2 : 4
  const target = items.some((item) => item.value === current && !item.disabled)
    ? layouts[current]
    : undefined
  const maxScroll = Math.max(0, contentWidth - viewport)
  const hasOverflow = OVERFLOW && maxScroll > 1
  useEffect(() => {
    if (!target) {
      width.set(0)
      initialized.current = false
      return
    }
    if (!initialized.current) {
      left.set(target.x)
      width.set(target.width)
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
      initialized.current = true
    } else {
      left.set(withSpring(target.x, { ...SPRING, reduceMotion: motion }))
      width.set(withSpring(target.width, { ...SPRING, reduceMotion: motion }))
      indicatorHeight.set(target.height)
      indicatorTop.set(target.y)
    }
    if (!viewport) return
    const offset = Math.max(0, Math.min(maxScroll, scrollX.get()))
    const safeLeft = offset + (hasOverflow && offset > 1 ? 40 : 0)
    const safeRight =
      offset + viewport - (hasOverflow && offset < maxScroll - 1 ? 40 : 0)
    const next =
      target.width > safeRight - safeLeft
        ? target.x - (hasOverflow ? 40 : 0)
        : target.x < safeLeft
          ? offset + target.x - safeLeft
          : target.x + target.width > safeRight
            ? offset + target.x + target.width - safeRight
            : offset
    scroll.current?.scrollTo({
      x: Math.max(0, Math.min(maxScroll, next)),
      animated: !reduce,
    })
  }, [
    target,
    viewport,
    maxScroll,
    hasOverflow,
    motion,
    reduce,
    left,
    width,
    indicatorHeight,
    indicatorTop,
    scrollX,
  ])
  const scrollHandler = useAnimatedScrollHandler((event) => {
    scrollX.set(event.contentOffset.x)
  })
  useAnimatedReaction(
    () => ({
      left: hasOverflow && scrollX.get() > 1,
      right: hasOverflow && scrollX.get() < maxScroll - 1,
    }),
    (next, previous) => {
      if (
        !previous ||
        next.left !== previous.left ||
        next.right !== previous.right
      )
        scheduleOnRN(setEdges, next)
    },
    [hasOverflow, maxScroll]
  )
  const indicatorStyle = useAnimatedStyle(() => ({
    width: width.get(),
    height: underline ? 1 : indicatorHeight.get(),
    top: underline ? undefined : indicatorTop.get(),
    transform: [{ translateX: left.get() }],
  }))
  const select = (item: TabItem) => {
    if (item.disabled || item.value === current) return
    if (value === undefined) setInternal(item.value)
    onValueChange?.(item.value)
  }
  const move = (direction: number) =>
    scroll.current?.scrollTo({
      x: Math.max(
        0,
        Math.min(maxScroll, scrollX.get() + direction * viewport * 0.8)
      ),
      animated: !reduce,
    })
  return (
    <View testID={testID} style={[{ alignItems: "flex-start" }, style]}>
      <View
        onLayout={({ nativeEvent }) => setViewport(nativeEvent.layout.width)}
        style={[
          styles.viewport,
          {
            width: OVERFLOW ? "100%" : undefined,
            maxWidth: "100%",
            backgroundColor: underline ? "transparent" : backgroundColor,
            borderRadius: radius,
          },
        ]}
      >
        <Animated.ScrollView
          ref={scroll}
          horizontal
          showsHorizontalScrollIndicator={false}
          bounces={false}
          onScroll={scrollHandler}
          scrollEventThrottle={16}
          directionalLockEnabled
          onContentSizeChange={(next) => setContentWidth(next)}
          style={{ flexGrow: 0, maxWidth: "100%" }}
        >
          <View
            accessibilityRole="tablist"
            accessibilityLabel={accessibilityLabel}
            style={[
              styles.list,
              {
                padding: inset,
                gap,
                borderBottomWidth: underline ? 1 : 0,
                borderColor,
              },
            ]}
          >
            <Animated.View
              pointerEvents="none"
              accessible={false}
              style={[
                styles.indicator,
                {
                  backgroundColor: activeColor,
                  borderRadius: underline ? 0 : radius,
                  bottom: underline ? 0 : undefined,
                },
                indicatorStyle,
              ]}
            />
            {items.map((item) => (
              <Pressable
                key={item.value}
                testID={testID ? `${testID}-${item.value}` : undefined}
                accessibilityRole="tab"
                accessibilityLabel={item.label}
                accessibilityState={{
                  selected: item.value === current,
                  disabled: !!item.disabled,
                }}
                aria-selected={item.value === current}
                aria-disabled={!!item.disabled}
                disabled={item.disabled}
                onPress={() => select(item)}
                hitSlop={{ top: 4, bottom: 4 }}
                onLayout={({ nativeEvent }) => {
                  const next = nativeEvent.layout
                  setLayouts((previous) => {
                    const old = previous[item.value]
                    return old &&
                      old.x === next.x &&
                      old.y === next.y &&
                      old.width === next.width &&
                      old.height === next.height
                      ? previous
                      : { ...previous, [item.value]: next }
                  })
                }}
                style={({ pressed }) => [
                  styles.trigger,
                  {
                    minHeight: Math.max(40, tabHeight),
                    paddingHorizontal: horizontalPadding,
                    paddingVertical: 6,
                    opacity: item.disabled ? 0.4 : pressed ? 0.8 : 1,
                  },
                ]}
              >
                <TabLabel
                  label={item.label}
                  geometry={layouts[item.value]}
                  left={left}
                  width={width}
                  {...{
                    textColor,
                    selectedTextColor,
                    fontSize,
                    labelStyle,
                    underline,
                    motion,
                  }}
                  active={item.value === current}
                />
              </Pressable>
            ))}
          </View>
        </Animated.ScrollView>
      </View>
      {items.map(
        (item) =>
          item.content != null && (
            <Panel
              key={item.value}
              testID={testID ? `${testID}-panel-${item.value}` : undefined}
              active={item.value === current && !item.disabled}
              motion={motion}
              reduce={reduce}
              style={contentStyle}
              contentGap={contentGap}
            >
              {typeof item.content === "string" ||
              typeof item.content === "number" ? (
                <Text style={{ color: contentTextColor, fontSize, lineHeight: 22 }}>
                  {item.content}
                </Text>
              ) : (
                item.content
              )}
            </Panel>
          )
      )}
    </View>
  )
}
const styles = StyleSheet.create({
  viewport: { overflow: "hidden", flexDirection: "row", direction: "ltr" },
  list: { position: "relative", flexDirection: "row", alignItems: "center" },
  indicator: { position: "absolute", left: 0 },
  trigger: { justifyContent: "center", alignItems: "center", flexShrink: 0 },
  mask: {
    position: "absolute",
    left: 0,
    top: 0,
    bottom: 0,
    overflow: "hidden",
  },
  maskLabel: { height: "100%", justifyContent: "center", alignItems: "center" },
  edge: {
    position: "absolute",
    top: 0,
    bottom: 0,
    width: 48,
    justifyContent: "center",
    alignItems: "center",
  },
  arrow: { width: 8, height: 8, borderRightWidth: 1.5, borderBottomWidth: 1.5 },
})

Test rapid reversals, long labels, large text and overflow swipes on a real Android phone

Back to Be UI