Gooey Switch
A gooey toggle switch with fluid blobs
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-gesture-handler @shopify/react-native-skia react-native-worklets @expo/vector-icons expo-symbolsCopy and paste the following code into your project.
component/micro-interactions/gooey-switch.tsx
// @ts-checkimport React, { memo, useEffect, useState } from "react";import { Platform, StyleSheet, type ViewStyle } from "react-native";import { Canvas, Group, Blur, ColorMatrix, Paint, Oval, Circle, RoundedRect,} from "@shopify/react-native-skia";import Animated, { useSharedValue, useAnimatedStyle, withSpring, interpolate, useDerivedValue, clamp, WithSpringConfig,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import { SymbolView } from "expo-symbols";import { DEFAULT_BLOB_COLOR, DEFAULT_ICON_COLOR, DEFAULT_OFF_COLOR, DEFAULT_ON_COLOR, DEFAULT_SIZE, DEFAULT_THRESHOLD,} from "./const";import type { IAnimatedBridge, ICoreOval, IGooeySwitch, IShadowOval,} from "./types";import { scheduleOnRN } from "react-native-worklets";import { Ionicons } from "@expo/vector-icons";const AnimatedMainOval: React.FC<ICoreOval> & React.FunctionComponent<ICoreOval> = ({ cx, cy, rx, ry, isOn, onColor, offColor,}: ICoreOval): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const color = useDerivedValue(() => { return isOn.value ? onColor : offColor; }); const x = useDerivedValue(() => cx.value - rx.value); const y = useDerivedValue(() => cy - ry.value); const width = useDerivedValue(() => rx.value * 2); const height = useDerivedValue<number>(() => ry.value * 2); return <Oval x={x} y={y} width={width} height={height} color={color} />;};const AnimatedShadowOval: React.FC<IShadowOval> & React.FunctionComponent<IShadowOval> = ({ cx, cy, rx, ry, color,}: IShadowOval): React.ReactNode & React.JSX.Element & React.ReactElement => { const x = useDerivedValue<number>(() => cx.value - rx.value); const y = useDerivedValue<number>(() => cy - ry.value); const width = useDerivedValue<number>(() => rx.value * 2); const height = useDerivedValue<number>(() => ry.value * 2); return <Oval x={x} y={y} width={width} height={height} color={color} />;};const AnimatedBridge: React.FC<IAnimatedBridge> & React.FunctionComponent<IAnimatedBridge> = ({ leftX, rightX, cy, mainX, height, color, progress,}: IAnimatedBridge): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const bridgeX = useDerivedValue<number>(() => { const p = progress.value; if (p <= 0.5) { return leftX; } return mainX.value; }); const bridgeWidth = useDerivedValue<Required<number>>(() => { const p = progress.value; if (p <= 0.5) { return mainX.value - leftX; } return rightX - mainX.value; }); const bridgeHeight = useDerivedValue<number>(() => { const p = progress.value; const stretchFactor = interpolate( p, [0, 0.25, 0.5, 0.75, 1], [0.6, 1, 0.8, 1, 0.6], ); return height * stretchFactor; }); const bridgeY = useDerivedValue<number>(() => { return cy - bridgeHeight.value / 2; }); const bridgeRadius = useDerivedValue<number>(() => { return bridgeHeight.value / 2; }); return ( <RoundedRect x={bridgeX} y={bridgeY} width={bridgeWidth} height={bridgeHeight} r={bridgeRadius} color={color} /> );};export const GooeySwitch: React.FC<IGooeySwitch> & React.FunctionComponent<IGooeySwitch> = memo<IGooeySwitch>( ({ active, onToggle, size = DEFAULT_SIZE, inactiveColor = DEFAULT_OFF_COLOR, activeColor = DEFAULT_ON_COLOR, trackColor = DEFAULT_BLOB_COLOR, iconTint = DEFAULT_ICON_COLOR, toggleThreshold = DEFAULT_THRESHOLD, isDisabled = false, showIcons = true, animation = {}, deformation = {}, connector = {}, blur, gooey = 35, renderActiveIcon, renderInactiveIcon, onDragBegin, onDragFinish, }: IGooeySwitch): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const [internalActive, setInternalActive] = useState<boolean>( active ?? false, ); const isControlled = active !== undefined; const currentActive = isControlled ? active : internalActive; const { stretchX = 1.18, squishY = 0.88, sideBlobScale = 0.82, } = deformation; const { show: connectorShow = true, height: connectorHeight = 0.35, offset: connectorOffset = 0, } = connector; const SWITCH_WIDTH = size; const SWITCH_HEIGHT = size * 0.6; const BLOB_RADIUS = size * 0.22; const SIDE_BLOB_RADIUS = BLOB_RADIUS * sideBlobScale; const ICON_SIZE = size * 0.12; const X_ICON_SIZE = size * 0.1; const BLUR_AMOUNT = blur ?? size * 0.1; const BRIDGE_HEIGHT = SWITCH_HEIGHT * connectorHeight; const LEFT_X = SWITCH_WIDTH * 0.28; const RIGHT_X = SWITCH_WIDTH * 0.72; const spring: WithSpringConfig = { damping: animation.damping ?? 15, stiffness: animation.stiffness ?? 120, mass: animation.mass ?? 0.8, }; const progress = useSharedValue<number>(currentActive ? 1 : 0); const isDragging = useSharedValue<boolean>(false); const isOn = useSharedValue<Required<boolean>>(currentActive); useEffect(() => { if (!isDragging.value) { progress.value = withSpring<number>(currentActive ? 1 : 0, spring); isOn.value = currentActive; } }, [currentActive]); const mainCircleX = useDerivedValue<number>(() => interpolate(progress.value, [0, 1], [LEFT_X, RIGHT_X]), ); const mainBlobRx = useDerivedValue<number>(() => { return ( BLOB_RADIUS * interpolate( progress.value, [0, 0.2, 0.5, 0.8, 1], [1, 1 + (stretchX - 1) * 0.6, stretchX, 1 + (stretchX - 1) * 0.6, 1], ) ); }); const mainBlobRy = useDerivedValue<number>(() => { return ( BLOB_RADIUS * interpolate( progress.value, [0, 0.2, 0.5, 0.8, 1], [1, 1 - (1 - squishY) * 0.6, squishY, 1 - (1 - squishY) * 0.6, 1], ) ); }); const innerBlobRx = useDerivedValue<number>(() => { return mainBlobRx.value - 1; }); const innerBlobRy = useDerivedValue<number>(() => { return mainBlobRy.value - 1; }); const updateValue = <T extends boolean>(newValue: T) => { if (!isControlled) { setInternalActive(newValue); } onToggle?.(newValue); }; const triggerDragStart = () => { onDragBegin?.(); }; const triggerDragEnd = <T extends boolean>(newValue: T) => { onDragFinish?.(newValue); }; const panGesture = Gesture.Pan() .enabled(!isDisabled) .onStart(() => { "worklet"; isDragging.value = true; if (onDragBegin) { scheduleOnRN<[], void>(triggerDragStart); } }) .onUpdate((event) => { "worklet"; const startX = currentActive ? RIGHT_X : LEFT_X; const newX = startX + event.translationX; const clampedX = clamp(newX, LEFT_X, RIGHT_X); const newProgress = interpolate(clampedX, [LEFT_X, RIGHT_X], [0, 1]); progress.value = newProgress; const shouldBeOn = newProgress >= toggleThreshold; if (shouldBeOn !== isOn.value) { isOn.value = shouldBeOn; } }) .onEnd((event) => { "worklet"; isDragging.value = false; const velocity = event.velocityX; const currentProgress = progress.value; let shouldBeOn: boolean; if (Math.abs(velocity) > 500) { shouldBeOn = velocity > 0; } else { shouldBeOn = currentProgress > toggleThreshold; } progress.value = withSpring(shouldBeOn ? 1 : 0, { ...spring, velocity: velocity / (RIGHT_X - LEFT_X), }); isOn.value = shouldBeOn; if (shouldBeOn !== currentActive) { scheduleOnRN(updateValue, shouldBeOn); } if (onDragFinish) { scheduleOnRN(triggerDragEnd, shouldBeOn); } }); const tapGesture = Gesture.Tap() .enabled(!isDisabled) .onEnd(() => { "worklet"; const newValue = !currentActive; progress.value = withSpring(newValue ? 1 : 0, spring); isOn.value = newValue; scheduleOnRN(updateValue, newValue); }); const composedGesture = Gesture.Race(panGesture, tapGesture); const iconContainerStyle = useAnimatedStyle<Pick<ViewStyle, "transform">>( () => ({ transform: [ { translateX: interpolate( progress.value, [0, 1], [-SWITCH_WIDTH * 0.22, SWITCH_WIDTH * 0.22], ), }, { scaleX: interpolate( progress.value, [0, 0.2, 0.5, 0.8, 1], [1, 1.08, 1.12, 1.08, 1], ), }, { scaleY: interpolate( progress.value, [0, 0.2, 0.5, 0.8, 1], [1, 0.94, 0.9, 0.94, 1], ), }, ], }), ); const activeIconStyle = useAnimatedStyle< Pick<ViewStyle, "opacity" | "transform"> >(() => ({ opacity: interpolate(progress.value, [0.6, 1], [0, 1]), transform: [{ scale: interpolate(progress.value, [0.6, 1], [0.5, 1]) }], })); const inactiveIconStyle = useAnimatedStyle< Pick<ViewStyle, "opacity" | "transform"> >(() => ({ opacity: interpolate(progress.value, [0, 0.4], [1, 0]), transform: [{ scale: interpolate(progress.value, [0, 0.4], [1, 0.5]) }], })); const containerStyle = [ styles.container, { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, opacity: isDisabled ? 0.5 : 1, }, ]; const colorMatrix = [ 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, gooey, -14, ]; const defaultActiveIcon = () => Platform.OS === "ios" ? ( <SymbolView name="checkmark" size={ICON_SIZE} tintColor={iconTint} weight="bold" /> ) : ( <> <Ionicons name="checkmark" size={ICON_SIZE} color={iconTint} /> </> ); const defaultInactiveIcon = () => Platform.OS === "ios" ? ( <SymbolView name="xmark" size={X_ICON_SIZE} tintColor={iconTint} weight="semibold" /> ) : ( <> <Ionicons name="close-outline" size={X_ICON_SIZE} color={iconTint} /> </> ); return ( <GestureDetector gesture={composedGesture}> <Animated.View style={containerStyle}> <Canvas style={[ styles.canvas, { width: SWITCH_WIDTH, height: SWITCH_HEIGHT }, ]} > <Group layer={ <Paint> <Blur blur={BLUR_AMOUNT} /> <ColorMatrix matrix={colorMatrix} /> </Paint> } > <Circle cx={LEFT_X} cy={SWITCH_HEIGHT / 2} r={SIDE_BLOB_RADIUS} color={trackColor} /> <Circle cx={RIGHT_X} cy={SWITCH_HEIGHT / 2} r={SIDE_BLOB_RADIUS} color={trackColor} /> {connectorShow && ( <AnimatedBridge leftX={LEFT_X} rightX={RIGHT_X} cy={SWITCH_HEIGHT / 2 + connectorOffset} mainX={mainCircleX} height={BRIDGE_HEIGHT} color={trackColor} progress={progress} /> )} <AnimatedShadowOval cx={mainCircleX} cy={SWITCH_HEIGHT / 2} rx={mainBlobRx} ry={mainBlobRy} color={trackColor} /> </Group> <AnimatedMainOval cx={mainCircleX} cy={SWITCH_HEIGHT / 2} rx={innerBlobRx} ry={innerBlobRy} isOn={isOn} onColor={activeColor} offColor={inactiveColor} /> </Canvas> {showIcons && ( <Animated.View style={[ styles.iconContainer, iconContainerStyle, { width: BLOB_RADIUS * 2, height: BLOB_RADIUS * 2 }, ]} > <Animated.View style={[styles.iconWrapper, activeIconStyle]}> {renderActiveIcon ? renderActiveIcon({ size: ICON_SIZE, color: iconTint }) : defaultActiveIcon()} </Animated.View> <Animated.View style={[styles.iconWrapper, inactiveIconStyle]}> {renderInactiveIcon ? renderInactiveIcon({ size: X_ICON_SIZE, color: iconTint }) : defaultInactiveIcon()} </Animated.View> </Animated.View> )} </Animated.View> </GestureDetector> ); },);export default memo< React.FC<IGooeySwitch> & React.FunctionComponent<IGooeySwitch>>(GooeySwitch);const styles = StyleSheet.create({ container: { justifyContent: "center", alignItems: "center", }, canvas: { position: "absolute", }, iconContainer: { justifyContent: "center", alignItems: "center", }, iconWrapper: { position: "absolute", },});Usage
import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { CountdownTimer } from "@/components/micro-interactions/countdown";import { Ionicons } from "@expo/vector-icons";import { FlexiButton } from "@/components/micro-interactions/flexi-button";import GooeySwitch from "@/components/micro-interactions/gooey-switch";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"), }); const launchDate = new Date("2026-07-20T14:30:00"); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <GooeySwitch activeColor="#8093ff" size={200} trackColor="#1a1a1a" gooey={35} deformation={{ squishY: 0.5, stretchX: 1.2, }} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, content: { alignItems: "center", gap: 24, top: 80, }, iconBox: { width: 64, height: 64, borderRadius: 20, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", marginBottom: 8, }, label: { fontSize: 14, color: "#555", textTransform: "uppercase", letterSpacing: 2, }, date: { fontSize: 15, color: "#333", marginTop: 8, },});Props
IBlobConfig
React Native Reanimated
React Native Gesture Handler
Expo Blur
Expo Symbols
React Native Skia
Expo Vector Icons
React Native Worklets
