Button
Split Button
One action stretches into three choices with a soft spring
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
split-button.tsx
import React, { useEffect, useId, useState } from "react"
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
ReduceMotion,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
type SharedValue,
} from "react-native-reanimated"
import Svg, {
Defs,
FeGaussianBlur,
Filter,
G,
Path,
Rect,
Text as SvgText,
} from "react-native-svg"
const WEB = Platform.OS === "web"
const SYSTEM_FONT = WEB
? "Arial"
: Platform.OS === "ios"
? "Helvetica"
: "sans-serif"
const MORPH = {
stiffness: 191.2003280898203,
damping: 12.444768609851868,
mass: 1,
reduceMotion: ReduceMotion.System,
} as const
const PRESS = {
stiffness: 550,
damping: 30,
mass: 1,
reduceMotion: ReduceMotion.System,
} as const
const DEFAULT_BUTTONS = ["iOS", "macOS", "tvOS"]
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (WEB) {
const amount = props.style?.stdDeviation ?? props.stdDeviation
const element = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (key: string, value: string) => void
} | null
}
}
).elementRef?.current
if (amount !== undefined)
element?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AnimatedBlur = Animated.createAnimatedComponent(BlurTarget)
type ButtonProps = {
x: number
width: number
label?: string
back?: boolean
color: string
background: string
fontSize: number
radius: number
disabled: boolean
primary?: boolean
onPress: () => void
testID?: string
progress: SharedValue<number>
}
function NativeButton({
x,
width,
label,
back,
color,
background,
fontSize,
radius,
disabled,
primary,
onPress,
testID,
progress,
}: ButtonProps) {
const height = back ? 48 : fontSize * 1.5 + 24
const scale = useSharedValue(1)
const reduced = useReducedMotion()
const filterId = `button-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`
const blur = useAnimatedProps(() => {
const amount = reduced
? 0
: Math.max(0, 8 * (primary ? progress.get() : 1 - progress.get())) /
scale.get()
return {
stdDeviation: amount,
stdDeviationX: amount,
stdDeviationY: amount,
}
})
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.get() }],
}))
return (
<Animated.View
style={[
{
position: "absolute",
left: x,
top: (48 - height) / 2,
width,
height,
},
style,
]}
>
<Svg
width={width + 48}
height={height + 48}
style={{ position: "absolute", left: -24, top: -24 }}
accessible={false}
pointerEvents="none"
>
<Defs>
<Filter id={filterId} x="-100%" y="-200%" width="300%" height="500%">
<AnimatedBlur animatedProps={blur} />
</Filter>
</Defs>
<G transform="translate(24 24)" filter={`url(#${filterId})`}>
<PaintedButton
x={0}
width={width}
label={label}
back={back}
color={color}
background={background}
fontSize={fontSize}
radius={radius}
/>
</G>
</Svg>
<Pressable
accessibilityRole="button"
accessibilityLabel={back ? "Back" : label}
disabled={disabled}
accessibilityState={{ disabled }}
testID={testID}
onPress={onPress}
onPressIn={() =>
scale.set(
withSpring(primary || back ? 1.15 : 1.05, primary ? MORPH : PRESS)
)
}
onPressOut={() => scale.set(withSpring(1, primary ? MORPH : PRESS))}
onHoverIn={() => scale.set(withSpring(1, primary ? MORPH : PRESS))}
hitSlop={0}
pressRetentionOffset={12}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
)
}
function PaintedButton({
x,
width,
label,
back,
color,
background,
fontSize,
radius,
}: Omit<ButtonProps, "disabled" | "onPress" | "progress">) {
const height = back ? 48 : fontSize * 1.5 + 24
return (
<G>
<Rect
x={x}
y={0}
width={width}
height={height}
rx={Math.min(radius, height / 2)}
fill={background}
/>
{back ? (
<Path
d={`M${x + 27} 18C${x + 27} 18 ${x + 21.00001} 22.4189 ${x + 21} 24C${x + 20.99999} 25.5812 ${x + 27} 30 ${x + 27} 30`}
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
) : (
<SvgText
x={x + width / 2}
y={height / 2 + fontSize * 0.35}
fontSize={fontSize}
fontFamily={SYSTEM_FONT}
fontWeight="600"
letterSpacing={fontSize * -0.025}
fill={color}
textAnchor="middle"
>
{label}
</SvgText>
)}
</G>
)
}
export type SplitButtonProps = {
mainButton?: string
buttons?: string[]
onSelect?: (name: string, index: number) => void
theme?: "light" | "dark" | "system"
backgroundColor?: string
textColor?: string
radius?: number
fontSize?: number
style?: StyleProp<ViewStyle>
testID?: string
}
export default function SplitButton({
mainButton = "New Project",
buttons = DEFAULT_BUTTONS,
onSelect,
theme = "system",
backgroundColor,
textColor,
radius = 999,
fontSize = 14,
style,
testID,
}: SplitButtonProps = {}) {
const system = useColorScheme()
const dark = theme === "dark" || (theme === "system" && system === "dark")
const { fontScale } = useWindowDimensions()
const reduced = useReducedMotion()
const [open, setOpen] = useState(false)
const [sizes, setSizes] = useState<Record<string, number>>({})
const progress = useSharedValue(0)
const color = textColor ?? (dark ? "#e5e5e5" : "#404040")
const background = backgroundColor ?? (dark ? "#262626" : "#e5e5e5")
const labels = [mainButton, ...buttons]
const ready = labels.every((label) => sizes[label] !== undefined)
const mainWidth = (sizes[mainButton] ?? 0) + 64
const widths = buttons.map((label) => (sizes[label] ?? 0) + 48)
const positions: number[] = []
let rowWidth = 48
widths.forEach((width) => {
rowWidth += 8
positions.push(rowWidth)
rowWidth += width
})
useEffect(() => {
progress.set(withSpring(open ? 1 : 0, MORPH))
}, [open, progress])
const mainStyle = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, 1 - progress.get())),
transform: [
{ scaleX: reduced ? 1 : 1 + progress.get() * 0.5 },
{ scaleY: reduced ? 1 : 1 - progress.get() * 0.1 },
],
}))
const rowStyle = useAnimatedStyle(() => ({
opacity: Math.max(0, Math.min(1, progress.get())),
transform: [
{ scaleX: reduced ? 1 : 0.2 + progress.get() * 0.8 },
{ scaleY: reduced ? 1 : 0.9 + progress.get() * 0.1 },
],
}))
return (
<View style={[styles.wrapper, style]} testID={testID}>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={styles.measure}
>
{labels.map((label, index) => (
<Text
key={`${index}-${label}`}
numberOfLines={1}
style={{
fontSize,
fontFamily: SYSTEM_FONT,
fontWeight: "600",
letterSpacing: fontSize * -0.025,
}}
onLayout={({ nativeEvent }) => {
const measured = nativeEvent.layout.width
if (measured !== undefined)
setSizes((prev) =>
prev[label] === measured
? prev
: { ...prev, [label]: measured }
)
}}
>
{label}
</Text>
))}
</View>
{ready && (
<>
<Animated.View
pointerEvents={open ? "auto" : "none"}
accessibilityElementsHidden={!open}
importantForAccessibility={open ? "auto" : "no-hide-descendants"}
style={[styles.layer, { width: rowWidth }, rowStyle]}
>
<NativeButton
progress={progress}
x={0}
width={48}
back
color={color}
background={background}
fontSize={fontSize * fontScale}
radius={radius}
disabled={!open}
onPress={() => setOpen(false)}
testID={testID ? `${testID}-back` : undefined}
/>
{buttons.map((label, index) => (
<NativeButton
progress={progress}
key={index}
x={positions[index]}
width={widths[index]}
label={label}
color={color}
background={background}
fontSize={fontSize * fontScale}
radius={radius}
disabled={!open}
onPress={() => {
setOpen(false)
onSelect?.(label, index)
}}
testID={testID ? `${testID}-option-${index}` : undefined}
/>
))}
</Animated.View>
<Animated.View
pointerEvents={open ? "none" : "auto"}
accessibilityElementsHidden={open}
importantForAccessibility={open ? "no-hide-descendants" : "auto"}
style={[styles.layer, { width: mainWidth, zIndex: 1 }, mainStyle]}
>
<NativeButton
progress={progress}
x={0}
width={mainWidth}
primary
label={mainButton}
color={color}
background={background}
fontSize={fontSize * fontScale}
radius={radius}
disabled={open}
onPress={() => setOpen(true)}
testID={testID ? `${testID}-main` : undefined}
/>
</Animated.View>
</>
)}
</View>
)
}
const styles = StyleSheet.create({
wrapper: {
width: "100%",
minHeight: 60,
alignItems: "center",
justifyContent: "center",
overflow: "visible",
},
layer: { position: "absolute", height: 48, alignSelf: "center" },
measure: {
position: "absolute",
opacity: 0,
alignItems: "flex-start",
pointerEvents: "none",
},
})
Give the gestures and motion a run on a real Android phone.