Created
September 4, 2025 23:16
-
-
Save prince272/bdc2c137919ccb3fd906df8de1119926 to your computer and use it in GitHub Desktop.
A pressable component with animated ripple effects on touch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ComponentProps, FC, ReactNode, useEffect, useMemo, useRef } from "react"; | |
import { | |
ColorValue, | |
EasingFunction, | |
GestureResponderEvent, | |
LayoutChangeEvent, | |
Pressable, | |
PressableStateCallbackType, | |
StyleProp, | |
StyleSheet, | |
View, | |
ViewStyle | |
} from "react-native"; | |
import Animated, { | |
cancelAnimation, | |
Easing, | |
interpolate, | |
SharedValue, | |
useAnimatedStyle, | |
useSharedValue, | |
withTiming | |
} from "react-native-reanimated"; | |
const AnimatedView = Animated.createAnimatedComponent(View); | |
interface RippleValue { | |
scale: SharedValue<number>; | |
opacity: SharedValue<number>; | |
centerX: SharedValue<number>; | |
centerY: SharedValue<number>; | |
radius: SharedValue<number>; | |
active: SharedValue<number>; | |
} | |
export interface RippleProps { | |
rippleOverflow?: boolean; | |
rippleCenter?: boolean; | |
rippleColor?: ColorValue; | |
rippleDuration?: number; | |
rippleSizeDivisor?: number; | |
disableRipple?: boolean; | |
disableAnimation?: boolean; | |
pressScale?: number; | |
pressOpacity?: number; | |
pressDuration?: number; | |
pressEasing?: EasingFunction; | |
} | |
export interface PressableRippleProps | |
extends Omit<ComponentProps<typeof Pressable>, "disabled">, | |
RippleProps { | |
isDisabled?: boolean; | |
} | |
function useRippleValue(): RippleValue { | |
const scale = useSharedValue(0); | |
const opacity = useSharedValue(0); | |
const centerX = useSharedValue(0); | |
const centerY = useSharedValue(0); | |
const radius = useSharedValue(0); | |
const active = useSharedValue(0); | |
return { | |
scale, | |
opacity, | |
centerX, | |
centerY, | |
radius, | |
active | |
}; | |
} | |
export const PressableRipple: FC<PressableRippleProps> = ({ | |
rippleOverflow = false, | |
rippleCenter = false, | |
rippleColor = "rgba(0,0,0,0.2)", | |
rippleDuration = 250, | |
rippleSizeDivisor = 6, | |
disableRipple = false, | |
pressScale = 0.95, | |
pressOpacity = 1, | |
pressDuration = 150, | |
pressEasing = Easing.out(Easing.ease), | |
disableAnimation = false, | |
className, | |
style, | |
children, | |
onPress, | |
isDisabled = false, | |
...rest | |
}) => { | |
const pressed = useSharedValue(false); | |
const hovered = useSharedValue(false); | |
const layoutRef = useRef({ width: 0, height: 0 }); | |
// Ripple pool determined by the number of createRippleValue calls | |
const ripplePool: RippleValue[] = [useRippleValue(), useRippleValue(), useRippleValue()]; | |
const pressAnimatedStyle = useAnimatedStyle(() => { | |
if (disableAnimation || isDisabled) { | |
return { transform: [{ scale: 1 }], opacity: 1 }; | |
} | |
return { | |
transform: [ | |
{ | |
scale: withTiming(pressed.value ? pressScale : 1, { | |
duration: pressDuration, | |
easing: pressEasing | |
}) | |
} | |
], | |
opacity: withTiming(pressed.value ? pressOpacity : 1, { | |
duration: pressDuration, | |
easing: pressEasing | |
}) | |
}; | |
}, [disableAnimation, isDisabled, pressScale, pressOpacity, pressDuration, pressEasing]); | |
const findInactiveRipple = (): RippleValue | null => | |
ripplePool.find((r) => r.active.value === 0) || null; | |
const handlePressIn = (e: GestureResponderEvent) => { | |
if (isDisabled) return; | |
if (!disableRipple) { | |
const { width, height } = layoutRef.current; | |
const locationX = e.nativeEvent.locationX; | |
const locationY = e.nativeEvent.locationY; | |
const cx = rippleCenter ? width / 2 : locationX; | |
const cy = rippleCenter ? height / 2 : locationY; | |
const maxX = Math.max(cx, width - cx); | |
const maxY = Math.max(cy, height - cy); | |
const r = Math.sqrt(maxX * maxX + maxY * maxY); | |
const ripple = findInactiveRipple(); | |
if (ripple) { | |
cancelAnimation(ripple.scale); | |
cancelAnimation(ripple.opacity); | |
ripple.centerX.value = cx; | |
ripple.centerY.value = cy; | |
ripple.radius.value = r; | |
ripple.active.value = 1; | |
ripple.scale.value = 0; | |
ripple.opacity.value = 0.8; | |
ripple.scale.value = withTiming(1, { | |
duration: rippleDuration, | |
easing: Easing.out(Easing.ease) | |
}); | |
} | |
} | |
pressed.value = true; | |
rest.onPressIn?.(e); | |
}; | |
const handlePressOut = (e: GestureResponderEvent) => { | |
if (isDisabled) return; | |
if (!disableRipple) { | |
ripplePool.forEach((ripple) => { | |
if (ripple.active.value === 1) { | |
ripple.opacity.value = withTiming( | |
0, | |
{ duration: 200, easing: Easing.out(Easing.ease) }, | |
(finished) => { | |
if (finished) { | |
ripple.scale.value = 0; | |
ripple.active.value = 0; | |
} | |
} | |
); | |
} | |
}); | |
} | |
pressed.value = false; | |
rest.onPressOut?.(e); | |
}; | |
const handlePress = (e: GestureResponderEvent) => { | |
if (isDisabled) return; | |
onPress?.(e); | |
}; | |
const handleHoverIn = (e: any) => { | |
if (isDisabled) return; | |
hovered.value = true; | |
rest.onHoverIn?.(e); | |
}; | |
const handleHoverOut = (e: any) => { | |
if (isDisabled) return; | |
hovered.value = false; | |
rest.onHoverOut?.(e); | |
}; | |
const onLayout = (e: LayoutChangeEvent) => { | |
layoutRef.current.width = e.nativeEvent.layout.width; | |
layoutRef.current.height = e.nativeEvent.layout.height; | |
rest.onLayout?.(e); | |
}; | |
const borderRadii = useMemo(() => borderRadiusFromClassName(className), [className]); | |
const containerStyle = useMemo( | |
() => [styles.container, { overflow: rippleOverflow ? "visible" : "hidden" }, borderRadii], | |
[rippleOverflow, borderRadii] | |
); | |
return ( | |
<AnimatedView style={pressAnimatedStyle}> | |
<View style={containerStyle as StyleProp<ViewStyle>} onLayout={onLayout}> | |
<Pressable | |
{...rest} | |
disabled={isDisabled} | |
style={style} | |
className={className} | |
onPressIn={handlePressIn} | |
onPressOut={handlePressOut} | |
onPress={handlePress} | |
onHoverIn={handleHoverIn} | |
onHoverOut={handleHoverOut} | |
> | |
{typeof children === "function" | |
? children({ | |
pressed: pressed.value, | |
hovered: hovered.value | |
} as PressableStateCallbackType) | |
: children} | |
{!disableRipple && | |
!isDisabled && | |
ripplePool.map((ripple, index) => ( | |
<RippleComponent | |
key={index} | |
ripple={ripple} | |
rippleColor={rippleColor} | |
rippleSizeDivisor={rippleSizeDivisor} | |
/> | |
))} | |
</Pressable> | |
</View> | |
</AnimatedView> | |
); | |
}; | |
export interface ToggleableRippleProps { | |
isActive: boolean; | |
rippleColor?: ColorValue; | |
rippleDuration?: number; | |
rippleSizeDivisor?: number; | |
rippleOverflow?: boolean; | |
rippleCenter?: boolean; | |
style?: ComponentProps<typeof View>["style"]; | |
className?: string; | |
children?: ReactNode; | |
} | |
export const ToggleableRipple: FC<ToggleableRippleProps> = ({ | |
isActive, | |
rippleColor = "rgba(0, 0, 0, 0.2)", | |
rippleDuration = 250, | |
rippleSizeDivisor = 6, | |
rippleOverflow = false, | |
rippleCenter = false, | |
className, | |
style, | |
children | |
}) => { | |
const containerRef = useRef<View>(null); | |
const scale = useSharedValue(0); | |
const opacity = useSharedValue(0); | |
const centerX = useSharedValue(0); | |
const centerY = useSharedValue(0); | |
const radius = useSharedValue(0); | |
const active = useSharedValue(0); | |
const ripple: RippleValue = useMemo( | |
() => ({ | |
scale, | |
opacity, | |
centerX, | |
centerY, | |
radius, | |
active | |
}), | |
[scale, opacity, centerX, centerY, radius, active] | |
); | |
useEffect(() => { | |
if (isActive) { | |
// Activate ripple | |
if (containerRef.current) { | |
containerRef.current.measure((x, y, width, height) => { | |
const cx = rippleCenter ? width / 2 : width / 2; | |
const cy = rippleCenter ? height / 2 : height / 2; | |
const maxX = Math.max(cx, width - cx); | |
const maxY = Math.max(cy, height - cy); | |
const r = Math.sqrt(maxX * maxX + maxY * maxY); | |
cancelAnimation(scale); | |
cancelAnimation(opacity); | |
centerX.value = cx; | |
centerY.value = cy; | |
radius.value = r; | |
active.value = 1; | |
scale.value = 0; | |
opacity.value = 0.8; | |
scale.value = withTiming(1, { | |
duration: rippleDuration, | |
easing: Easing.out(Easing.ease) | |
}); | |
}); | |
} | |
} else { | |
// Deactivate ripple | |
if (active.value === 1) { | |
opacity.value = withTiming( | |
0, | |
{ duration: rippleDuration / 2, easing: Easing.out(Easing.ease) }, | |
(finished) => { | |
if (finished) { | |
scale.value = 0; | |
active.value = 0; | |
} | |
} | |
); | |
} | |
} | |
}, [isActive, rippleDuration, rippleCenter, scale, opacity, centerX, centerY, radius, active]); | |
const borderRadii = useMemo(() => borderRadiusFromClassName(className), [className]); | |
return ( | |
<View | |
ref={containerRef} | |
style={[ | |
styles.container, | |
{ overflow: rippleOverflow ? "visible" : "hidden" }, | |
borderRadii, | |
style | |
]} | |
className={className} | |
> | |
{children} | |
<RippleComponent | |
ripple={ripple} | |
rippleColor={rippleColor} | |
rippleSizeDivisor={rippleSizeDivisor} | |
/> | |
</View> | |
); | |
}; | |
const RippleComponent: FC<{ | |
ripple: RippleValue; | |
rippleColor: ColorValue; | |
rippleSizeDivisor: number; | |
}> = ({ ripple, rippleColor, rippleSizeDivisor }) => { | |
const rippleStyle = useAnimatedStyle(() => { | |
const scaleValue = interpolate( | |
ripple.scale.value, | |
[0, 1], | |
[0, ripple.radius.value / rippleSizeDivisor] | |
); | |
return { | |
zIndex: -1, | |
position: "absolute", | |
top: ripple.centerY.value - 8, | |
left: ripple.centerX.value - 8, | |
width: 16, | |
height: 16, | |
borderRadius: 8, | |
backgroundColor: rippleColor, | |
transform: [{ scale: scaleValue }], | |
opacity: ripple.opacity.value * ripple.active.value | |
}; | |
}); | |
return <AnimatedView pointerEvents="none" style={rippleStyle} />; | |
}; | |
const styles = StyleSheet.create({ | |
container: { position: "relative" } | |
}); | |
function borderRadiusFromClassName(className?: string): { | |
borderRadius?: number; | |
borderTopLeftRadius?: number; | |
borderTopRightRadius?: number; | |
borderBottomLeftRadius?: number; | |
borderBottomRightRadius?: number; | |
} { | |
if (!className) return {}; | |
const result: any = {}; | |
const tokens = className.split(/\s+/); | |
const predefined: Record<string, number> = { | |
none: 0, | |
sm: 2, | |
"": 4, | |
md: 6, | |
lg: 8, | |
xl: 12, | |
"2xl": 16, | |
"3xl": 24, | |
full: 9999 | |
}; | |
const parseValue = (val: string) => { | |
if (val.startsWith("[")) { | |
return parseInt(val.slice(1, -1), 10); | |
} | |
return predefined[val] ?? 4; | |
}; | |
tokens.forEach((token) => { | |
if (!token.startsWith("rounded")) return; | |
let parts = token.split("-"); | |
switch (parts[1]) { | |
case undefined: | |
result.borderRadius = parseValue(parts[1] ?? ""); | |
break; | |
case "t": | |
result.borderTopLeftRadius = result.borderTopRightRadius = parseValue(parts[2]); | |
break; | |
case "r": | |
result.borderTopRightRadius = result.borderBottomRightRadius = parseValue(parts[2]); | |
break; | |
case "b": | |
result.borderBottomLeftRadius = result.borderBottomRightRadius = parseValue(parts[2]); | |
break; | |
case "l": | |
result.borderTopLeftRadius = result.borderBottomLeftRadius = parseValue(parts[2]); | |
break; | |
case "tl": | |
result.borderTopLeftRadius = parseValue(parts[2]); | |
break; | |
case "tr": | |
result.borderTopRightRadius = parseValue(parts[2]); | |
break; | |
case "br": | |
result.borderBottomRightRadius = parseValue(parts[2]); | |
break; | |
case "bl": | |
result.borderBottomLeftRadius = parseValue(parts[2]); | |
break; | |
default: | |
result.borderRadius = parseValue(parts[1]); | |
} | |
}); | |
return result; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment