Skip to content
pixelfor Expo
Ctrl K

Button

Morphing Button

A blurred bell gives way to an email field and a sliding action

Try it on your phone

Morphing Button

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

morphing-button.tsx

import React, {
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useId,
  useImperativeHandle,
  useRef,
  useState,
} from "react"
import {
  PixelRatio,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewProps,
  type ViewStyle,
  type TextStyle,
} from "react-native"
import Animated, {
  Easing,
  ReduceMotion,
  interpolateColor,
  useAnimatedProps,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Defs, FeGaussianBlur, Filter, G, Path } from "react-native-svg"
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const SPRING = {
  stiffness: 240,
  damping: 18,
  mass: 1.1,
  reduceMotion: ReduceMotion.System,
} as const
const COLOR = {
  duration: 300,
  easing: Easing.bezier(0.4, 0, 0.2, 1),
  reduceMotion: ReduceMotion.System,
} as const
const LABEL = {
  duration: 450,
  easing: Easing.bezier(0.4, 0, 0.1, 1),
  reduceMotion: ReduceMotion.System,
} as const
const AnimatedGroup = Animated.createAnimatedComponent(G)
const MODERN_SHADOW =
  Platform.OS !== "android" || Number(Platform.Version) >= 28
type Dismissal = { view: React.RefObject<View | null>; close: () => void }
const DismissContext = createContext<((entry: Dismissal) => () => void) | null>(
  null
)
export function MorphingButtonRoot({ children, style, ...props }: ViewProps) {
  const active = useRef<Dismissal | null>(null)
  const register = useCallback((entry: Dismissal) => {
    if (active.current && active.current !== entry) active.current.close()
    active.current = entry
    return () => {
      if (active.current === entry) active.current = null
    }
  }, [])
  return (
    <DismissContext.Provider value={register}>
      <View
        {...props}
        style={[{ flex: 1 }, style]}
        onStartShouldSetResponderCapture={(event) => {
          const entry = active.current
          const { pageX, pageY } = event.nativeEvent
          entry?.view.current?.measureInWindow((x, y, width, height) => {
            if (
              active.current === entry &&
              (pageX < x ||
                pageX > x + width ||
                pageY < y ||
                pageY > y + height)
            )
              entry.close()
          })
          return false
        }}
      >
        {children}
      </View>
    </DismissContext.Provider>
  )
}
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)
export type MorphingButtonHandle = { expand: () => void; collapse: () => void }
export type MorphingButtonProps = {
  buttonText?: string
  placeholder?: string
  onSubmit?: (email: string) => void
  theme?: "light" | "dark" | "system"
  backgroundColor?: string
  buttonColor?: string
  hoverColor?: string
  expandedHoverColor?: string
  textColor?: string
  placeholderColor?: string
  borderColor?: string
  expandedWidth?: number
  fontSize?: number
  radius?: number
  borderWidth?: number
  style?: StyleProp<ViewStyle>
  testID?: string
}
export const MorphingButton = forwardRef<
  MorphingButtonHandle,
  MorphingButtonProps
>(function MorphingButton(
  {
    buttonText = "Notify Me",
    placeholder = "Email",
    onSubmit,
    theme = "system",
    backgroundColor,
    buttonColor,
    hoverColor,
    expandedHoverColor,
    textColor,
    placeholderColor,
    borderColor,
    expandedWidth = 336,
    fontSize = 20,
    radius = 32,
    borderWidth = 1.1,
    style,
    testID,
  },
  forwardedRef
) {
  const system = useColorScheme(),
    { width: screenWidth, fontScale } = useWindowDimensions()
  const dark = theme === "dark" || (theme === "system" && system === "dark")
  const register = useContext(DismissContext)
  const view = useRef<View>(null),
    input = useRef<TextInput>(null)
  const expandedRef = useRef(false),
    inputPresent = useRef(false)
  const initialized = useRef(false)
  const [expanded, setExpanded] = useState(false),
    [email, setEmail] = useState("")
  const [labelWidth, setLabelWidth] = useState<number | null>(null)
  const reduced = useReducedMotion()
  const size = fontSize * fontScale,
    lineHeight = size * 1.4
  const border = PixelRatio.roundToNearestPixel(borderWidth)
  const closedButtonWidth = (labelWidth ?? 0) + 84
  const openButtonWidth = (labelWidth ?? 0) + 40
  const closedWidth = closedButtonWidth + border * 2
  const openWidth = Math.min(expandedWidth, screenWidth - 24)
  const fullHeight = lineHeight + 32
  const openX = openWidth - border * 2 - 4 - openButtonWidth
  const width = useSharedValue(closedWidth),
    buttonWidth = useSharedValue(closedButtonWidth)
  const buttonX = useSharedValue(0),
    buttonY = useSharedValue(0),
    buttonHeight = useSharedValue(fullHeight)
  const labelX = useSharedValue(60),
    bell = useSharedValue(1),
    inputOpacity = useSharedValue(0),
    inputX = useSharedValue(-10)
  const colorProgress = useSharedValue(0),
    hover = useSharedValue(0)
  const filter = `bell-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`
  const background = backgroundColor ?? (dark ? "#1c1c1e" : "#f4f4f4")
  const openedButton = buttonColor ?? (dark ? "#2c2c2e" : "#fefefe")
  const closedHover = hoverColor ?? (dark ? "#252529" : "#ebeaea")
  const openHover = expandedHoverColor ?? (dark ? "#3a3a3c" : "#fafafa")
  const text = textColor ?? (dark ? "#fefefe" : "#000")
  const collapse = useCallback(() => {
    expandedRef.current = false
    setExpanded(false)
    input.current?.blur()
  }, [])
  const expand = useCallback(() => {
    expandedRef.current = true
    setExpanded(true)
  }, [])
  useImperativeHandle(forwardedRef, () => ({ expand, collapse }), [
    expand,
    collapse,
  ])
  const forgetInput = useCallback(() => {
    inputPresent.current = false
  }, [])
  useEffect(() => {
    if (!expanded || !register) return
    return register({ view, close: collapse })
  }, [expanded, register, collapse])
  useEffect(() => {
    if (labelWidth === null) return
    width.set(
      initialized.current
        ? withSpring(expanded ? openWidth : closedWidth, SPRING)
        : closedWidth
    )
    buttonWidth.set(
      initialized.current
        ? withSpring(expanded ? openButtonWidth : closedButtonWidth, SPRING)
        : closedButtonWidth
    )
    initialized.current = true
    buttonX.set(withSpring(expanded ? openX : 0, SPRING))
    buttonY.set(withSpring(expanded ? 4 : 0, SPRING))
    buttonHeight.set(
      withSpring(expanded ? lineHeight + 24 : fullHeight, SPRING)
    )
    labelX.set(withTiming(expanded ? 20 : 60, LABEL))
    bell.set(withSpring(expanded ? 0 : 1, SPRING))
    colorProgress.set(withTiming(expanded ? 1 : 0, COLOR))
    if (expanded) {
      if (!inputPresent.current) inputX.set(-10)
      inputPresent.current = true
      inputX.set(withSpring(0, SPRING))
      inputOpacity.set(withSpring(1, SPRING))
      input.current?.focus()
    } else
      inputOpacity.set(
        withSpring(0, SPRING, (finished) => {
          if (finished) scheduleOnRN(forgetInput)
        })
      )
  }, [
    expanded,
    labelWidth,
    openWidth,
    closedWidth,
    openButtonWidth,
    closedButtonWidth,
    openX,
    lineHeight,
    fullHeight,
    width,
    buttonWidth,
    buttonX,
    buttonY,
    buttonHeight,
    labelX,
    bell,
    inputX,
    inputOpacity,
    colorProgress,
    forgetInput,
  ])
  const containerStyle = useAnimatedStyle(() => ({ width: width.get() }))
  const buttonStyle = useAnimatedStyle(() => ({
    left: buttonX.get(),
    top: buttonY.get(),
    width: buttonWidth.get(),
    height: buttonHeight.get(),
    borderRadius: buttonHeight.get() / 2,
    backgroundColor: interpolateColor(
      hover.get(),
      [0, 1],
      [
        interpolateColor(
          colorProgress.get(),
          [0, 1],
          [background, openedButton]
        ),
        interpolateColor(colorProgress.get(), [0, 1], [closedHover, openHover]),
      ]
    ),
  }))
  const labelStyle = useAnimatedStyle(() => ({
    left: labelX.get(),
    top: (buttonHeight.get() - lineHeight) / 2,
  }))
  const svgStyle = useAnimatedStyle(() => ({
    top: (buttonHeight.get() - 72) / 2,
  }))
  const inputStyle = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, inputOpacity.get())),
    transform: [{ translateX: reduced ? 0 : inputX.get() }],
  }))
  const bellProps = useAnimatedProps(() => {
    const p = bell.get(),
      scale = reduced ? 1 : p
    const x = 48 * (1 - scale),
      y = 36 * (1 - scale)
    return {
      opacity: Math.max(0, Math.min(1, p)),
      ...(WEB
        ? { transform: `matrix(${scale} 0 0 ${scale} ${x} ${y})` }
        : { matrix: [scale, 0, 0, scale, x, y] }),
    }
  })
  const blur = useAnimatedProps(() => {
    const amount = reduced ? 0 : Math.max(0, 4 * (1 - bell.get()))
    return {
      stdDeviation: amount,
      stdDeviationX: amount,
      stdDeviationY: amount,
    }
  })
  const submit = () => {
    if (!expandedRef.current) {
      expand()
      return
    }
    if (!email) return
    onSubmit?.(email)
    collapse()
    setEmail("")
  }
  const shadows = (button: boolean): ViewStyle =>
    !expanded
      ? {}
      : MODERN_SHADOW
        ? {
            boxShadow: dark
              ? button
                ? [
                    {
                      offsetX: 0,
                      offsetY: 10,
                      blurRadius: 15,
                      spreadDistance: -3,
                      color: "rgba(0,0,0,.1)",
                    },
                    {
                      offsetX: 0,
                      offsetY: 4,
                      blurRadius: 6,
                      spreadDistance: -4,
                      color: "rgba(0,0,0,.1)",
                    },
                  ]
                : [
                    {
                      offsetX: 0,
                      offsetY: 20,
                      blurRadius: 25,
                      spreadDistance: -5,
                      color: "rgba(0,0,0,.1)",
                    },
                    {
                      offsetX: 0,
                      offsetY: 8,
                      blurRadius: 10,
                      spreadDistance: -6,
                      color: "rgba(0,0,0,.1)",
                    },
                  ]
              : [
                  {
                    offsetX: 0,
                    offsetY: 1,
                    blurRadius: 3,
                    color: "rgba(0,0,0,.1)",
                  },
                  {
                    offsetX: 0,
                    offsetY: 1,
                    blurRadius: 2,
                    spreadDistance: -1,
                    color: "rgba(0,0,0,.1)",
                  },
                ],
          }
        : { elevation: button ? 2 : 3 }
  return (
    <View style={[styles.wrapper, style]} testID={testID}>
      <Text
        accessible={false}
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        aria-hidden
        allowFontScaling={false}
        numberOfLines={1}
        style={[
          styles.measure,
          {
            fontFamily: FONT,
            fontWeight: "700",
            fontSize: size,
            letterSpacing: -size * 0.025,
          },
        ]}
        onLayout={({ nativeEvent }) => setLabelWidth(nativeEvent.layout.width)}
      >
        {buttonText}
      </Text>
      {labelWidth !== null && (
        <Animated.View
          ref={view}
          collapsable={false}
          style={[
            {
              height: fullHeight + border * 2,
              borderWidth: border,
              borderColor:
                borderColor ?? (dark ? "rgba(255,255,255,.05)" : "#e7e6e6a6"),
              borderRadius: radius,
              backgroundColor: background,
              overflow: "hidden",
            },
            shadows(false),
            containerStyle,
          ]}
        >
          <Animated.View
            pointerEvents={expanded ? "auto" : "none"}
            accessibilityElementsHidden={!expanded}
            importantForAccessibility={
              expanded ? "auto" : "no-hide-descendants"
            }
            aria-hidden={!expanded}
            style={[
              {
                position: "absolute",
                left: 4,
                top: (fullHeight - lineHeight) / 2,
                width: Math.max(
                  0,
                  openWidth - border * 2 - 8 - openButtonWidth
                ),
                paddingHorizontal: 16,
              },
              inputStyle,
            ]}
          >
            <TextInput
              ref={input}
              accessibilityLabel={placeholder}
              placeholder={placeholder}
              placeholderTextColor={
                placeholderColor ?? (dark ? "#b2b2b2" : "#a1a1aa")
              }
              value={email}
              editable={expanded}
              tabIndex={expanded ? 0 : -1}
              onChangeText={setEmail}
              keyboardType="email-address"
              autoCapitalize="none"
              autoCorrect={false}
              returnKeyType="send"
              submitBehavior="submit"
              underlineColorAndroid="transparent"
              selectionColor={text}
              onSubmitEditing={() => {
                if (email) submit()
              }}
              testID={testID ? `${testID}-input` : undefined}
              style={{
                width: "100%",
                height: lineHeight,
                padding: 0,
                borderWidth: 0,
                outlineWidth: 0,
                outlineStyle: WEB
                  ? ("none" as TextStyle["outlineStyle"])
                  : undefined,
                fontFamily: FONT,
                fontSize,
                fontWeight: "600",
                color: textColor ?? (dark ? "#fefefe" : "#18181b"),
              }}
            />
          </Animated.View>
          <Animated.View
            style={[{ position: "absolute" }, shadows(true), buttonStyle]}
          >
            <Animated.View
              pointerEvents="none"
              style={[
                { position: "absolute", left: 0, width: 72, height: 72 },
                svgStyle,
              ]}
            >
              <Svg width={72} height={72} accessible={false}>
                <Defs>
                  <Filter
                    id={filter}
                    x="-100%"
                    y="-100%"
                    width="300%"
                    height="300%"
                  >
                    <AnimatedBlur animatedProps={blur} />
                  </Filter>
                </Defs>
                <AnimatedGroup
                  animatedProps={bellProps}
                  filter={`url(#${filter})`}
                >
                  <G transform={`translate(25.5 24) scale(${24 / 512})`}>
                    <Path
                      fill={dark ? text : "rgba(0,0,0,.9)"}
                      d="M224 0c-17.7 0-32 14.3-32 32l0 19.2C119 66 64 130.6 64 208l0 18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416l384 0c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8l0-18.8c0-77.4-55-142-128-156.8L256 32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"
                    />
                  </G>
                </AnimatedGroup>
              </Svg>
            </Animated.View>
            <Animated.Text
              pointerEvents="none"
              accessible={false}
              accessibilityElementsHidden
              importantForAccessibility="no-hide-descendants"
              aria-hidden
              numberOfLines={1}
              allowFontScaling={false}
              style={[
                {
                  position: "absolute",
                  fontFamily: FONT,
                  fontSize: size,
                  lineHeight,
                  fontWeight: "700",
                  letterSpacing: -size * 0.025,
                  color: text,
                },
                labelStyle,
              ]}
            >
              {buttonText}
            </Animated.Text>
            <Pressable
              accessibilityRole="button"
              accessibilityLabel={buttonText}
              accessibilityState={{ expanded }}
              aria-expanded={expanded}
              onPress={submit}
              onHoverIn={() => hover.set(withTiming(1, COLOR))}
              onHoverOut={() => hover.set(withTiming(0, COLOR))}
              testID={testID ? `${testID}-button` : undefined}
              style={StyleSheet.absoluteFill}
            />
          </Animated.View>
        </Animated.View>
      )}
    </View>
  )
})
const styles = StyleSheet.create({
  wrapper: {
    width: "100%",
    padding: 32,
    alignItems: "center",
    justifyContent: "center",
  },
  measure: { position: "absolute", opacity: 0, pointerEvents: "none" },
})

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

Back to Watermelon UI