Interaction
Fluid 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.
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.