Skip to content
pixelfor Expo
Ctrl K

Interaction

Discrete Tabs

An expressive interaction rebuilt for touch

Try it on your phone

Discrete Tabs

Scan to open Discrete 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

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI