Veltrix

skia shaders

Photon Tap

A tap emits directional particles that return to their origin and confirm the action.

AppleiOSAndroidAndroidExpoExpo Go
skiahapticsreanimatedgesture handler
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add @shopify/react-native-skia expo-haptics react-native-reanimated react-native-gesture-handler

Install tailwind variants

pnpm add tailwind-variants

Usage

PhotonTapExample.tsx
import { Text } from "react-native";import { PhotonTap } from "@animations/ui/components/skia-shaders/photon-tap/uniwind";export function PhotonTapExample() {  return (    <PhotonTap      accessibilityLabel="Save changes"      className="rounded-2xl"      onPress={() => console.log("Changes saved")}    >      <Text className="font-extrabold">Save changes</Text>    </PhotonTap>  );}

Component files

6 files

photon-tap/config.ts
export const PHOTON_TAP_CONFIG = {  borderRadius: 18,  direction: 0,  duration: 720,  maximumParticles: 16,  particleCount: 9,  particleDistance: 56,  particleSize: 2,  pressScale: 0.975,} as const;export const PHOTON_TAP_COLORS = [  "#ffffff",  "#a5f3fc",  "#38bdf8",  "#60a5fa",] as const;export const PHOTON_TAP_PARTICLES = Array.from(  { length: PHOTON_TAP_CONFIG.maximumParticles },  (_, index) => index,);
photon-tap/photon-layer.tsx
import {  BlurMask,  Canvas,  Circle,  Group,} from "@shopify/react-native-skia";import { StyleSheet } from "react-native";import {  useDerivedValue,  type SharedValue,} from "react-native-reanimated";import {  PHOTON_TAP_COLORS,  PHOTON_TAP_CONFIG,  PHOTON_TAP_PARTICLES,} from "./config";type PhotonParticleProps = {  color: string;  direction: number;  distance: number;  enabled: boolean;  index: number;  origin: SharedValue<{ x: number; y: number }>;  progress: SharedValue<number>;  size: number;};function PhotonParticle({  color,  direction,  distance,  enabled,  index,  origin,  progress,  size,}: PhotonParticleProps) {  const angle =    direction +    index * 2.399963229728653 +    (index % 3) * 0.07;  const delay = (index % 3) * 0.04;  const particleProgress = useDerivedValue(() =>    Math.min(      1,      Math.max(0, (progress.get() - delay) / (1 - delay)),    ),  );  const travel = useDerivedValue(    () =>      Math.sin(particleProgress.get() * Math.PI) *      distance *      (0.72 + (index % 4) * 0.09),  );  const tangentTravel = useDerivedValue(    () =>      Math.pow(        Math.sin(particleProgress.get() * Math.PI),        2,      ) *      (index % 2 === 0 ? 1 : -1) *      (2.5 + (index % 3)),  );  const cx = useDerivedValue(() =>    origin.get().x +    Math.cos(angle) * travel.get() -    Math.sin(angle) * tangentTravel.get(),  );  const cy = useDerivedValue(() =>    origin.get().y +    Math.sin(angle) * travel.get() +    Math.cos(angle) * tangentTravel.get(),  );  const opacity = useDerivedValue(() =>    enabled      ? Math.pow(          Math.sin(particleProgress.get() * Math.PI),          0.58,        )      : 0,  );  const radius = useDerivedValue(() =>    size *    (0.65 + (index % 4) * 0.11) *    (0.82 +      Math.sin(particleProgress.get() * Math.PI) * 0.32),  );  const glowRadius = useDerivedValue(    () => radius.get() * 1.85,  );  const coreRadius = useDerivedValue(    () => radius.get() * 0.34,  );  return (    <>      <Circle        color={color}        cx={cx}        cy={cy}        opacity={opacity}        r={glowRadius}      >        <BlurMask blur={3.5} style="solid" />      </Circle>      <Circle        color={color}        cx={cx}        cy={cy}        opacity={opacity}        r={radius}      />      <Circle        color="#ffffff"        cx={cx}        cy={cy}        opacity={opacity}        r={coreRadius}      />    </>  );}type PhotonCoreProps = {  color: string;  origin: SharedValue<{ x: number; y: number }>;  progress: SharedValue<number>;};function PhotonCore({  color,  origin,  progress,}: PhotonCoreProps) {  const flashRadius = useDerivedValue(    () => 3 + Math.min(progress.get() / 0.24, 1) * 8,  );  const flashOpacity = useDerivedValue(() =>    Math.max(0, 1 - progress.get() / 0.28),  );  const ringRadius = useDerivedValue(    () => 5 + Math.min(progress.get() / 0.45, 1) * 18,  );  const ringOpacity = useDerivedValue(() =>    Math.max(0, 0.72 - progress.get() / 0.5),  );  return (    <>      <Circle        color={color}        c={origin}        opacity={flashOpacity}        r={flashRadius}      >        <BlurMask blur={6} style="solid" />      </Circle>      <Circle        color="#ffffff"        c={origin}        opacity={flashOpacity}        r={3.2}      />      <Circle        color={color}        c={origin}        opacity={ringOpacity}        r={ringRadius}        strokeWidth={1.4}        style="stroke"      />    </>  );}type PhotonLayerProps = {  colors?: readonly string[];  direction: number;  particleCount: number;  particleDistance: number;  particleSize: number;  origin: SharedValue<{ x: number; y: number }>;  progress: SharedValue<number>;};export function PhotonLayer({  colors = PHOTON_TAP_COLORS,  direction,  particleCount,  particleDistance,  particleSize,  origin,  progress,}: PhotonLayerProps) {  const palette =    colors.length > 0 ? colors : PHOTON_TAP_COLORS;  return (    <Canvas      pointerEvents="none"      style={StyleSheet.absoluteFill}    >      <Group blendMode="screen">        <PhotonCore          color={palette[0]}          origin={origin}          progress={progress}        />        {PHOTON_TAP_PARTICLES.map((index) => (          <PhotonParticle            color={palette[index % palette.length]}            direction={direction}            distance={particleDistance}            enabled={index < particleCount}            index={index}            key={index}            origin={origin}            progress={progress}            size={particleSize}          />        ))}      </Group>    </Canvas>  );}
photon-tap/types.ts
import type { ReactNode } from "react";export type PhotonTapBaseProps = {  accessibilityHint?: string;  accessibilityLabel: string;  borderRadius?: number;  children: ReactNode;  colors?: readonly string[];  direction?: number;  disabled?: boolean;  duration?: number;  haptics?: boolean;  onPress?: () => void;  particleCount?: number;  particleDistance?: number;  particleSize?: number;};
photon-tap/uniwind/index.tsx
import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { PHOTON_TAP_CONFIG } from "../config";import { PhotonLayer } from "../photon-layer";import type { PhotonTapBaseProps } from "../types";import { usePhotonTap } from "../use-photon-tap";import {  photonTapVariants,  type PhotonTapSlot,} from "./variants";export type PhotonTapProps = PhotonTapBaseProps & {  className?: string;  classNames?: SlotsToClasses<PhotonTapSlot>;};export function PhotonTap({  accessibilityHint = "Activates the action with a particle confirmation.",  accessibilityLabel,  borderRadius = PHOTON_TAP_CONFIG.borderRadius,  children,  className,  classNames,  colors,  disabled,  direction = PHOTON_TAP_CONFIG.direction,  duration,  haptics,  onPress,  particleCount = PHOTON_TAP_CONFIG.particleCount,  particleDistance = PHOTON_TAP_CONFIG.particleDistance,  particleSize = PHOTON_TAP_CONFIG.particleSize,}: PhotonTapProps) {  const {    accessibilityState,    animatedStyle,    emit,    gesture,    onLayout,    origin,    progress,  } = usePhotonTap({    disabled,    duration,    haptics,    onPress,  });  const { container } = photonTapVariants({ disabled });  return (    <GestureDetector gesture={gesture}>      <Animated.View        accessibilityHint={accessibilityHint}        accessibilityLabel={accessibilityLabel}        accessibilityRole="button"        accessibilityState={accessibilityState}        accessible        className={container({          className: [className, classNames?.container],        })}        onAccessibilityTap={emit}        onLayout={onLayout}        style={[{ borderRadius }, animatedStyle]}      >        {children}        <PhotonLayer          colors={colors}          direction={direction}          origin={origin}          particleCount={particleCount}          particleDistance={particleDistance}          particleSize={particleSize}          progress={progress}        />      </Animated.View>    </GestureDetector>  );}
photon-tap/uniwind/variants.ts
import { tv } from "tailwind-variants";export const photonTapSlots = {  container: "overflow-hidden",} as const;export type PhotonTapSlot = keyof typeof photonTapSlots;export const photonTapVariants = tv({  slots: photonTapSlots,  variants: {    disabled: {      true: {        container: "opacity-[0.45]",      },    },  },});
photon-tap/use-photon-tap.ts
import { useCallback, useMemo, useState } from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import {  Easing,  runOnJS,  useAnimatedStyle,  useReducedMotion,  useSharedValue,  withTiming,} from "react-native-reanimated";import { PHOTON_TAP_CONFIG } from "./config";import type { PhotonTapBaseProps } from "./types";type UsePhotonTapOptions = Pick<  PhotonTapBaseProps,  "disabled" | "duration" | "haptics" | "onPress">;export function usePhotonTap({  disabled = false,  duration = PHOTON_TAP_CONFIG.duration,  haptics = true,  onPress,}: UsePhotonTapOptions) {  const origin = useSharedValue({ x: 0, y: 0 });  const progress = useSharedValue(1);  const pressed = useSharedValue(0);  const [size, setSize] = useState({ height: 0, width: 0 });  const reducedMotion = useReducedMotion();  const onLayout = useCallback(    (event: LayoutChangeEvent) => {      const { height, width } = event.nativeEvent.layout;      setSize((currentSize) => {        if (          currentSize.height === height &&          currentSize.width === width        ) {          return currentSize;        }        return { height, width };      });    },    [],  );  const confirm = useCallback(() => {    if (disabled) {      return;    }    if (haptics) {      void Haptics.impactAsync(        Haptics.ImpactFeedbackStyle.Light,      );    }    onPress?.();  }, [    disabled,    haptics,    onPress,  ]);  const emit = useCallback(() => {    if (disabled) {      return;    }    origin.set({      x: size.width / 2,      y: size.height / 2,    });    progress.set(0);    progress.set(      reducedMotion        ? 1        : withTiming(1, {            duration,            easing: Easing.out(Easing.cubic),          }),    );    confirm();  }, [    confirm,    disabled,    duration,    origin,    progress,    reducedMotion,    size,  ]);  const gesture = useMemo(    () =>      Gesture.Tap()        .enabled(!disabled)        .onBegin((event) => {          origin.set({ x: event.x, y: event.y });          pressed.set(            reducedMotion              ? 1              : withTiming(1, { duration: 90 }),          );        })        .onEnd((_event, success) => {          if (!success) {            return;          }          progress.set(0);          progress.set(            reducedMotion              ? 1              : withTiming(1, {                  duration,                  easing: Easing.out(Easing.cubic),                }),          );          runOnJS(confirm)();        })        .onFinalize(() => {          pressed.set(            reducedMotion              ? 0              : withTiming(0, { duration: 160 }),          );        }),    [      disabled,      duration,      confirm,      origin,      pressed,      progress,      reducedMotion,    ],  );  const animatedStyle = useAnimatedStyle(() => ({    transform: [      {        scale:          1 -          pressed.get() *            (1 - PHOTON_TAP_CONFIG.pressScale),      },    ],  }));  return {    accessibilityState: { disabled },    animatedStyle,    emit,    gesture,    onLayout,    origin,    progress,  };}

API reference

Generated directly from the exported component props.

PhotonTap