Interaction
Discrete Tabs
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
discrete-tabs.tsx
import React, { useEffect, useState, type ReactNode } from "react"
import {
Platform,
Pressable,
Text,
View,
useColorScheme,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
LinearTransition,
cubicBezier,
cancelAnimation,
createAnimatedPropAdapter,
processColor,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from "react-native-reanimated"
import { LinearGradient } from "expo-linear-gradient"
import Svg, { type SvgProps } from "react-native-svg"
const AnimatedSvg = Animated.createAnimatedComponent(Svg)
const colorAdapter = createAnimatedPropAdapter(
(props) => {
if ("color" in props)
props.color = { type: 0, payload: processColor(props.color as string) }
},
["color"]
)
export interface DiscreteTabItem {
id: string
icon:
| ReactNode
| ((color: ReturnType<typeof useSharedValue<string>>) => ReactNode)
label: string
activeColor: string
}
export interface DiscreteTabsProps {
tabs: DiscreteTabItem[]
defaultTab?: string
onTabChange?: (tabId: string) => void
theme?: "light" | "dark" | "system"
backgroundColor?: string
borderColor?: string
inactiveColor?: string
height?: number
radius?: number
gap?: number
fontFamily?: string
style?: StyleProp<ViewStyle>
testID?: string
}
const POSITION = LinearTransition.springify().stiffness(210).damping(18).mass(1)
function Tab({
tab,
active,
choose,
dark,
background,
border,
inactive,
height,
radius,
fontFamily,
}: {
tab: DiscreteTabItem
active: boolean
choose: () => void
dark: boolean
background: string
border: string
inactive: string
height: number
radius: number
fontFamily?: string
}) {
const [labelWidth, setLabelWidth] = useState(0)
const iconColor = useSharedValue(active ? tab.activeColor : inactive)
useEffect(() => {
iconColor.set(
withTiming(active ? tab.activeColor : inactive, {
duration: 300,
easing: Easing.bezier(0.4, 0, 0.2, 1),
})
)
}, [active, tab.activeColor, inactive])
const iconProps = useAnimatedProps(
() => ({ color: iconColor.get() }),
[],
colorAdapter
)
const width = useSharedValue(0),
opacity = useSharedValue(active ? 1 : 0),
margin = useSharedValue(active ? 8 : 0),
shine = useSharedValue(-1.2)
useEffect(() => {
const timing = { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.35, 1) }
width.set(withTiming(active ? labelWidth : 0, timing))
opacity.set(withTiming(active ? 1 : 0, timing))
margin.set(withTiming(active ? 8 : 0, timing))
cancelAnimation(shine)
shine.set(-1.2)
if (active)
shine.set(
withDelay(
600,
withTiming(1.2, { duration: 500, easing: Easing.linear })
)
)
return () => cancelAnimation(shine)
}, [active, labelWidth])
const label = useAnimatedStyle(() => ({
width: width.get(),
opacity: opacity.get(),
marginLeft: margin.get(),
}))
const sweep = useAnimatedStyle(() => ({
transform: [{ translateX: shine.get() * labelWidth }],
}))
const color = active ? tab.activeColor : inactive
return (
<Animated.View
layout={POSITION}
style={{ height: 64, justifyContent: "center" }}
>
<Pressable
accessibilityRole="tab"
accessibilityLabel={tab.label}
accessibilityState={{ selected: active }}
onPress={choose}
style={{
height,
borderRadius: radius,
borderWidth: 1,
borderColor: border,
backgroundColor: background,
paddingHorizontal: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Animated.View
style={{
alignItems: "center",
justifyContent: "center",
transitionProperty: ["color"],
transitionDuration: 300,
}}
>
{typeof tab.icon === "function" ? (
tab.icon(iconColor)
) : React.isValidElement<SvgProps>(tab.icon) &&
tab.icon.type === Svg ? (
<AnimatedSvg {...tab.icon.props} animatedProps={iconProps} />
) : React.isValidElement<{ color?: string }>(tab.icon) ? (
React.cloneElement(tab.icon, { color })
) : (
tab.icon
)}
</Animated.View>
<Animated.View style={[{ overflow: "hidden", height: 28 }, label]}>
<Animated.Text
numberOfLines={1}
style={{
width: labelWidth,
fontSize: 20,
lineHeight: 28,
fontWeight: "600",
color,
fontFamily,
transitionProperty: "color",
transitionDuration: 300,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
}}
>
{tab.label}
</Animated.Text>
<Animated.View
pointerEvents="none"
style={[
{ position: "absolute", left: 0, top: 0, bottom: 0, width: 64 },
sweep,
]}
>
<LinearGradient
colors={[
"transparent",
dark ? "rgba(23,23,23,.8)" : "rgba(255,255,255,.8)",
"transparent",
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{ flex: 1 }}
/>
</Animated.View>
</Animated.View>
</Pressable>
<View pointerEvents="none" style={{ position: "absolute", opacity: 0 }}>
<Text
onLayout={(e) => setLabelWidth(e.nativeEvent.layout.width)}
style={{
fontSize: 20,
lineHeight: 28,
fontWeight: "600",
fontFamily,
}}
>
{tab.label}
</Text>
</View>
</Animated.View>
)
}
export function DiscreteTabs({
tabs,
defaultTab,
onTabChange,
theme = "system",
backgroundColor,
borderColor,
inactiveColor,
height = 48,
radius = 999,
gap = 8,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
style,
testID,
}: DiscreteTabsProps) {
const [active, setActive] = useState(defaultTab ?? tabs[0]?.id)
const scheme = useColorScheme(),
dark = theme === "dark" || (theme === "system" && scheme === "dark")
return (
<Animated.View
testID={testID}
layout={LinearTransition.duration(450).easing(
Easing.bezier(0.4, 0, 0.1, 1)
)}
style={[
{
alignSelf: "center",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap,
paddingVertical: 24,
borderRadius: 999,
overflow: "hidden",
},
style,
]}
>
{tabs.map((tab) => (
<Tab
key={tab.id}
tab={tab}
active={active === tab.id}
choose={() => {
setActive(tab.id)
onTabChange?.(tab.id)
}}
dark={dark}
background={backgroundColor ?? (dark ? "#18181b" : "#fafafa")}
border={borderColor ?? "#292929"}
inactive={inactiveColor ?? (dark ? "#fff" : "#262626")}
height={height}
radius={radius}
fontFamily={fontFamily}
/>
))}
</Animated.View>
)
}
export default DiscreteTabs
Give the gestures and motion a run on a real Android phone.