Skip to content
pixelfor Expo
Ctrl K

Button

Split Button

One action stretches into three choices with a soft spring

Try it on your phone

Split Button

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

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.

Back to Watermelon UI