Tabs
Four styles. One smooth transition.
Tabs / 01
Pill
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent 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