Skip to content

Instantly share code, notes, and snippets.

@prince272
Created September 4, 2025 23:16
Show Gist options
  • Save prince272/bdc2c137919ccb3fd906df8de1119926 to your computer and use it in GitHub Desktop.
Save prince272/bdc2c137919ccb3fd906df8de1119926 to your computer and use it in GitHub Desktop.
A pressable component with animated ripple effects on touch
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