Skia Ripple
A tap driven Skia ripple effect
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @shopify/react-native-skia react native gesture handlerCopy and paste the following code into your project.
component/organisms/skia-ripple
import React, { memo, useMemo } from "react";// @ts-checkimport { Canvas, RoundedRect, Skia, Group, Paint, RuntimeShader, rect, rrect, Image as SkiaImage, useImage, SkPath,} from "@shopify/react-native-skia";import { StyleSheet, View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import { RIPPLE_SHADER_SOURCE } from "./conf";import { useRipple } from "./hook";// @ts-nocheckimport type { IRippleSkiaEffect, IRippleImage, IRippleRect } from "./types";const RIPPLE_SHADER = Skia.RuntimeEffect.Make(RIPPLE_SHADER_SOURCE);const SkiaRippleEffect: React.FC<IRippleSkiaEffect> & React.FunctionComponent<IRippleSkiaEffect> = memo<IRippleSkiaEffect>( ({ width, height, children, amplitude = 12, frequency = 15, decay = 8, speed = 1200, duration = 4, borderRadius = 0, style, }: IRippleSkiaEffect): React.ReactNode & React.JSX.Element & React.ReactElement => { const { uniforms, tap } = useRipple({ amplitude, decay, duration, frequency, height, speed, width, }); const clipPath = useMemo<SkPath | null>(() => { if (borderRadius <= 0) return null; const path = Skia.Path.Make(); path.addRRect( rrect(rect(0, 0, width, height), borderRadius, borderRadius), ); return path; }, [width, height, borderRadius]); if (!RIPPLE_SHADER) { return ( <GestureDetector gesture={tap}> <View style={[{ width, height }, style]}> <Canvas style={{ width, height }}>{children}</Canvas> </View> </GestureDetector> ); } return ( <GestureDetector gesture={tap}> <View style={[{ width, height, borderRadius, overflow: "hidden" }, style]} > <Canvas style={{ width, height }}> <Group clip={clipPath ?? undefined} layer={ <Paint> <RuntimeShader source={RIPPLE_SHADER} uniforms={uniforms} /> </Paint> } > {children} </Group> </Canvas> </View> </GestureDetector> ); },);const RippleImage: React.FC<IRippleImage> & React.FunctionComponent<IRippleImage> = memo<IRippleImage>( ({ width, height, source, amplitude = 12, frequency = 15, decay = 8, speed = 1200, duration = 4, borderRadius = 0, style, fit = "cover", }: IRippleImage): React.ReactNode & React.JSX.Element & React.ReactElement => { const image = useImage(source); const { uniforms, tap } = useRipple({ amplitude, decay, duration, frequency, height, speed, width, }); const clipPath = useMemo<SkPath | null>(() => { if (borderRadius <= 0) return null; const path = Skia.Path.Make(); path.addRRect( rrect(rect(0, 0, width, height), borderRadius, borderRadius), ); return path; }, [width, height, borderRadius]); if (!RIPPLE_SHADER) { return ( <GestureDetector gesture={tap}> <View style={[{ width, height, borderRadius, overflow: "hidden" }, style]} > <Canvas style={{ width, height }}> {image && ( <SkiaImage image={image} x={0} y={0} width={width} height={height} fit={fit} /> )} </Canvas> </View> </GestureDetector> ); } return ( <GestureDetector gesture={tap}> <View style={[{ width, height, borderRadius, overflow: "hidden" }, style]} > <Canvas style={{ width, height }}> <Group clip={clipPath ?? undefined} layer={ <Paint> <RuntimeShader source={RIPPLE_SHADER} uniforms={uniforms} /> </Paint> } > {image && ( <SkiaImage image={image} x={0} y={0} width={width} height={height} fit={fit} /> )} </Group> </Canvas> </View> </GestureDetector> ); },);const RippleRect: React.FC<IRippleRect> & React.FunctionComponent<IRippleRect> = memo<IRippleRect>( ({ width, height, color, amplitude = 12, frequency = 15, decay = 8, speed = 1200, duration = 4, borderRadius = 0, style, children, }: IRippleRect): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const { uniforms, tap } = useRipple({ amplitude, decay, duration, frequency, height, speed, width, }); if (!RIPPLE_SHADER) { return ( <GestureDetector gesture={tap}> <View style={[ { width, height, borderRadius, overflow: "hidden" }, style, ]} > <Canvas style={{ width, height }}> <RoundedRect x={0} y={0} width={width} height={height} r={borderRadius} color={color} /> </Canvas> {children && ( <View style={[StyleSheet.absoluteFill, styles.container]}> {children} </View> )} </View> </GestureDetector> ); } return ( <GestureDetector gesture={tap}> <View style={[{ width, height, borderRadius, overflow: "hidden" }, style]} > <Canvas style={{ width, height }}> <Group layer={ <Paint> <RuntimeShader source={RIPPLE_SHADER} uniforms={uniforms} /> </Paint> } > <RoundedRect x={0} y={0} width={width} height={height} r={borderRadius} color={color} /> </Group> </Canvas> {children && ( <View style={[StyleSheet.absoluteFill, styles.container]}> {children} </View> )} </View> </GestureDetector> ); }, );const styles = StyleSheet.create({ container: { alignItems: "center", justifyContent: "center", pointerEvents: "none", },});export { SkiaRippleEffect, RippleImage, RippleRect };Usage
import { StyleSheet, Text, View } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { RippleImage, RippleRect } from "@/components/organisms/skia-ripple";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";const IMAGE_URL = "https://i.pinimg.com/736x/4e/7f/4f/4e7f4fc63374f90f75a80860bf4bc943.jpg";export default function App() { const [fontsLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); if (!fontsLoaded) return <></>; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="inverted" /> <View style={styles.cardWrapper}> <RippleImage width={350} height={420} borderRadius={28} source={IMAGE_URL} style={styles.imageCard} /> <View style={styles.cardOverlay}> <Text style={[ styles.cardTitle, { fontFamily: fontsLoaded ? "SfProRounded" : undefined, }, ]} > Sherliam </Text> <Text style={[ styles.cardSubtitle, { fontFamily: fontsLoaded ? "HelveticaNowDisplay" : undefined, }, ]} > Carries power he never asked for </Text> </View> </View> <RippleRect width={220} height={46} borderRadius={28} color="#101010" style={styles.button} > <View style={{ flexDirection: "row", alignItems: "center", gap: 8, }} > <SymbolView name="sparkle" tintColor={"white"} size={17} /> <Text style={[ styles.buttonText, { fontFamily: fontsLoaded ? "SfProRounded" : undefined, }, ]} > Reacticx is awesome! </Text> </View> </RippleRect> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", justifyContent: "center", gap: 32, }, cardWrapper: { borderRadius: 28, bottom: 100, shadowColor: "#000", shadowOpacity: 0.35, shadowRadius: 30, shadowOffset: { width: 0, height: 20 }, paddingHorizontal: 20, }, imageCard: { backgroundColor: "rgba(255,255,255,0.04)", borderWidth: StyleSheet.hairlineWidth, borderColor: "rgba(255,255,255,0.18)", }, cardOverlay: { position: "absolute", bottom: 20, left: 20, right: 20, }, cardTitle: { fontSize: 22, color: "#fff", }, cardSubtitle: { fontSize: 14, color: "rgba(255,255,255,0.7)", }, button: { backgroundColor: "rgba(255,255,255,0.08)", borderWidth: StyleSheet.hairlineWidth, borderColor: "rgba(255,255,255,0.16)", bottom: 120, left: 10, }, buttonText: { color: "#fff", fontSize: 15, },});Props
IRippleImage
React Native Reanimated
React Native Skia
React Native Gesture Handler
