skia shaders
Photon Tap
A tap emits directional particles that return to their origin and confirm the action.
iOSAndroidExpo Go
skiahapticsreanimatedgesture handler
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-handlerInstall tailwind variants
pnpm add tailwind-variantsUsage
PhotonTapExample.tsximport { 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> );}photon-tap/config.tsexport 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.tsximport { 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.tsimport 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.tsximport { 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.tsimport { 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.tsimport { 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, };}