Skia Ripple

A tap driven Skia ripple effect

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @shopify/react-native-skia react native gesture handler

Copy 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