Button
Morphing Button
A blurred bell gives way to an email field and a sliding action
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent 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.