Skip to content
pixelfor Expo
Ctrl K

Interaction

Fluid Tabs

An expressive interaction rebuilt for touch

Try it on your phone

Fluid Tabs

Scan to open Fluid 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.

fluid-tabs.tsx

import React, { useEffect, useId, useState, type ReactNode } from "react"
import {
  Platform,
  Pressable,
  Text,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import Svg, {
  Defs,
  Filter,
  FeGaussianBlur,
  G,
  Path,
  Text as SvgText,
} from "react-native-svg"
import { LinearGradient } from "expo-linear-gradient"
export interface FluidTabItem {
  id: string
  label: string
  icon: ReactNode
}
export interface FluidTabsProps {
  tabs?: FluidTabItem[]
  defaultActive?: string
  onChange?: (id: string) => void
  theme?: "light" | "dark" | "system"
  compact?: boolean
  radius?: number
  colors?: Partial<{
    surface: string
    border: string
    active: string
    inactive: string
    hover: string
    pillTop: string
    pillBottom: string
    pillBorder: string
  }>
  fontFamily?: string
  style?: StyleProp<ViewStyle>
  testID?: string
}
const ORIGINAL_ICONS = {
  deposits: {
    viewBox: "0 0 576 512",
    paths: [
      "M567.938 243.908L462.25 85.374A48.003 48.003 0 0 0 422.311 64H153.689a48 48 0 0 0-39.938 21.374L8.062 243.908A47.994 47.994 0 0 0 0 270.533V400c0 26.51 21.49 48 48 48h480c26.51 0 48-21.49 48-48V270.533a47.994 47.994 0 0 0-8.062-26.625zM162.252 128h251.497l85.333 128H376l-32 64H232l-32-64H76.918l85.334-128z",
    ],
  },
  funds: {
    viewBox: "0 0 24 24",
    paths: [
      "M13 2.051V11h8.949c-.47-4.717-4.232-8.479-8.949-8.949zm4.969 17.953c2.189-1.637 3.694-4.14 3.98-7.004h-8.183l4.203 7.004z",
      "M11 12V2.051C5.954 2.555 2 6.824 2 12c0 5.514 4.486 10 10 10a9.93 9.93 0 0 0 4.255-.964s-5.253-8.915-5.254-9.031A.02.02 0 0 0 11 12z",
    ],
  },
  accounts: {
    viewBox: "0 0 512 512",
    paths: [
      "M501.62 92.11L267.24 2.04a31.958 31.958 0 0 0-22.47 0L10.38 92.11A16.001 16.001 0 0 0 0 107.09V144c0 8.84 7.16 16 16 16h480c8.84 0 16-7.16 16-16v-36.91c0-6.67-4.14-12.64-10.38-14.98zM64 192v160H48c-8.84 0-16 7.16-16 16v48h448v-48c0-8.84-7.16-16-16-16h-16V192h-64v160h-96V192h-64v160h-96V192H64zm432 256H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h480c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z",
    ],
  },
}
function DefaultIcon({ kind }: { kind: keyof typeof ORIGINAL_ICONS }) {
  const icon = ORIGINAL_ICONS[kind]
  return (
    <Svg width={22} height={22} viewBox={icon.viewBox}>
      {icon.paths.map((d, index) => (
        <Path key={index} d={d} fill="currentColor" />
      ))}
    </Svg>
  )
}
const DEFAULT_TABS: FluidTabItem[] = [
  { id: "accounts", label: "Accounts", icon: <DefaultIcon kind="accounts" /> },
  { id: "deposits", label: "Deposits", icon: <DefaultIcon kind="deposits" /> },
  { id: "funds", label: "Funds", icon: <DefaultIcon kind="funds" /> },
]
class BlurTarget extends FeGaussianBlur {
  setNativeProps = (
    props: React.ComponentProps<typeof FeGaussianBlur> & {
      style?: React.ComponentProps<typeof FeGaussianBlur>
    }
  ) => {
    if (Platform.OS === "web") {
      const node = (
        this as unknown as {
          elementRef?: {
            current: {
              setAttribute: (key: string, value: string) => void
            } | null
          }
        }
      ).elementRef?.current
      const amount = props.style?.stdDeviation ?? props.stdDeviation
      if (amount !== undefined)
        node?.setAttribute("stdDeviation", String(amount))
    } else this.root?.setNativeProps(props)
  }
}
const AnimatedBlur = Animated.createAnimatedComponent(BlurTarget)
const AnimatedG = Animated.createAnimatedComponent(G)
function Content({
  tab,
  active,
  width,
  height,
  gap,
  fontSize,
  fontFamily,
  activeColor,
  inactiveColor,
  hoverColor,
  hovered,
}: {
  tab: FluidTabItem
  active: boolean
  width: number
  height: number
  gap: number
  fontSize: number
  fontFamily?: string
  activeColor: string
  inactiveColor: string
  hoverColor: string
  hovered: boolean
}) {
  const id = useId().replace(/\W/g, "")
  const blur = useSharedValue(0),
    scale = useSharedValue(active ? 1.03 : 1),
    color = useSharedValue(active ? activeColor : inactiveColor)
  useEffect(() => {
    color.set(
      withTiming(active ? activeColor : hovered ? hoverColor : inactiveColor, {
        duration: 200,
        easing: Easing.bezier(0.4, 0, 0.2, 1),
      })
    )
  }, [active, hovered, activeColor, inactiveColor, hoverColor])
  useEffect(() => {
    scale.set(
      withSpring(active ? 1.03 : 1, { stiffness: 300, damping: 15, mass: 1 })
    )
    const transition = { duration: 150, easing: Easing.bezier(0, 0, 0.58, 1) }
    blur.set(
      active
        ? withSequence(withTiming(4, transition), withTiming(0, transition))
        : withTiming(0, { duration: 300, easing: transition.easing })
    )
  }, [active])
  const filter = useAnimatedProps(() => ({
    stdDeviation: Math.max(0, blur.get()),
  }))
  const icon = useAnimatedProps(() => ({
    transform: `translate(11 11) scale(${scale.get()}) translate(-11 -11)`,
  }))
  const group = useAnimatedProps(() => ({
    fill: color.get(),
    color: color.get(),
  }))
  return (
    <Svg width={width} height={height} style={{ overflow: "visible" }}>
      <Defs>
        <Filter id={id} x="-30%" y="-60%" width="160%" height="220%">
          <AnimatedBlur animatedProps={filter} />
        </Filter>
      </Defs>
      <AnimatedG filter={`url(#${id})`} animatedProps={group}>
        <AnimatedG x={0} y={(height - 22) / 2} animatedProps={icon}>
          {tab.icon}
        </AnimatedG>
        <SvgText
          x={22 + gap}
          y={height / 2 + fontSize * 0.35}
          fontSize={fontSize}
          fontFamily={fontFamily}
          fontWeight={active ? "700" : "600"}
          letterSpacing={-fontSize * 0.025}
        >
          {tab.label}
        </SvgText>
      </AnimatedG>
    </Svg>
  )
}
export function FluidTabs({
  tabs = DEFAULT_TABS,
  defaultActive = tabs[0]?.id,
  onChange,
  theme = "system",
  compact,
  radius = 999,
  colors: overrides,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  style,
  testID,
}: FluidTabsProps) {
  const [active, setActive] = useState(defaultActive),
    [measurements, setMeasurements] = useState<
      Record<string, { x: number; width: number; height: number }>
    >({}),
    [labels, setLabels] = useState<Record<string, number>>({}),
    [hovered, setHovered] = useState<string | null>(null)
  const scheme = useColorScheme(),
    window = useWindowDimensions(),
    small = compact ?? window.width < 640,
    dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    surface: dark ? "#171717" : "#f5f1eb",
    border: dark ? "#262626" : "#f5f1ebf4",
    active: dark ? "#fff" : "#292926",
    inactive: dark ? "#737373" : "#585652",
    hover: dark ? "#d4d4d4" : "#585652",
    pillTop: dark ? "#404040" : "#fefefe",
    pillBottom: dark ? "rgba(38,38,38,.9)" : "rgba(249,250,251,.8)",
    pillBorder: dark ? "rgba(82,82,82,.5)" : "rgba(254,254,254,.9)",
    ...overrides,
  }
  const pillX = useSharedValue(0),
    pillWidth = useSharedValue(0),
    pillHeight = useSharedValue(0),
    visible = useSharedValue(0)
  useEffect(() => {
    const m = measurements[active]
    if (!m) return
    const spring = { stiffness: 280, damping: 25, mass: 0.8 }
    if (!visible.get()) {
      pillX.set(m.x)
      pillWidth.set(m.width)
      pillHeight.set(m.height)
      visible.set(1)
    } else {
      pillX.set(withSpring(m.x, spring))
      pillWidth.set(withSpring(m.width, spring))
      pillHeight.set(withSpring(m.height, spring))
    }
  }, [active, measurements])
  const pill = useAnimatedStyle(() => ({
    opacity: visible.get(),
    width: pillWidth.get(),
    height: pillHeight.get(),
    transform: [{ translateX: pillX.get() }],
  }))
  const fontSize = small ? 14 : 16,
    innerGap = small ? 6 : 12,
    padding = small ? 12 : 16,
    height = small ? 42 : 50
  return (
    <View
      testID={testID}
      style={[
        {
          alignSelf: "center",
          flexDirection: "row",
          gap: small ? 4 : 8,
          borderRadius: radius,
          borderWidth: 1.6,
          borderColor: colors.border,
          backgroundColor: colors.surface,
          padding: 4,
        },
        style,
      ]}
    >
      <Animated.View
        pointerEvents="none"
        style={[
          {
            position: "absolute",
            top: 4,
            left: 0,
            borderRadius: radius,
            borderWidth: 1,
            borderColor: colors.pillBorder,
            overflow: "hidden",
            boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
          },
          pill,
        ]}
      >
        <LinearGradient
          colors={[colors.pillTop, colors.pillBottom]}
          style={{ flex: 1 }}
        />
      </Animated.View>
      {tabs.map((tab) => {
        const labelWidth = labels[tab.id] ?? tab.label.length * fontSize * 0.58,
          contentWidth = 22 + innerGap + labelWidth
        return (
          <Pressable
            key={tab.id}
            accessibilityRole="tab"
            accessibilityLabel={tab.label}
            accessibilityState={{ selected: active === tab.id }}
            onPress={() => {
              setActive(tab.id)
              onChange?.(tab.id)
            }}
            onHoverIn={() => setHovered(tab.id)}
            onHoverOut={() => setHovered(null)}
            onPressIn={() => setHovered(tab.id)}
            onPressOut={() => setHovered(null)}
            onLayout={(e) => {
              const { x, width, height } = e.nativeEvent.layout
              setMeasurements((old) =>
                old[tab.id]?.x === x &&
                old[tab.id]?.width === width &&
                old[tab.id]?.height === height
                  ? old
                  : { ...old, [tab.id]: { x, width, height } }
              )
            }}
            style={{
              height,
              paddingHorizontal: padding,
              justifyContent: "center",
              borderRadius: radius,
            }}
          >
            <Content
              tab={tab}
              active={tab.id === active}
              width={contentWidth}
              height={height}
              gap={innerGap}
              fontSize={fontSize}
              fontFamily={fontFamily}
              activeColor={colors.active}
              inactiveColor={colors.inactive}
              hoverColor={colors.hover}
              hovered={hovered === tab.id}
            />
            <View
              pointerEvents="none"
              style={{ position: "absolute", opacity: 0 }}
            >
              <Text
                onLayout={(e) => {
                  const width = e.nativeEvent.layout.width
                  setLabels((old) =>
                    old[tab.id] === width ? old : { ...old, [tab.id]: width }
                  )
                }}
                style={{
                  fontFamily,
                  fontSize,
                  fontWeight: active === tab.id ? "700" : "600",
                  letterSpacing: -fontSize * 0.025,
                }}
              >
                {tab.label}
              </Text>
            </View>
          </Pressable>
        )
      })}
    </View>
  )
}
export default FluidTabs

Give the gestures and motion a run on a real Android phone.

Back to Watermelon UI