gestures
Snap Mosaic
A displaced mosaic piece that reacts to proximity and snaps into a valid target.
iOSAndroidExpo Go
reanimatedworkletsgesture handler
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-worklets react-native-gesture-handleranatomy.tsx<SnapMosaic> <SnapMosaic.Board /> <SnapMosaic.Piece /></SnapMosaic>Install tailwind variants
pnpm add tailwind-variantsUsage
SnapMosaicExample.tsximport { Text, View } from "react-native";import { SnapMosaic } from "@animations/ui/components/gestures/snap-mosaic/uniwind";export function SnapMosaicExample() { return ( <View className="h-56"> <SnapMosaic accessibilityLabel="Complete profile mosaic" onCompletedChange={(completed) => console.log({ completed })} restingOffset={{ x: 84, y: 48 }} > <SnapMosaic.Board> <View className="flex-1 bg-neutral-950" /> </SnapMosaic.Board> <SnapMosaic.Piece> <View className="flex-1 items-center justify-center"> <Text className="font-black text-white">SNAP</Text> </View> </SnapMosaic.Piece> </SnapMosaic> </View> );}snap-mosaic/config.tsexport const SNAP_MOSAIC_CONFIG = { defaultRestingOffset: { x: 72, y: 42, }, defaultSnapRadius: 52, pieceSize: 72, projectionFactor: 0.08, spring: { damping: 17, mass: 0.72, stiffness: 220, },} as const;snap-mosaic/types.tsimport type { ReactNode } from "react";export type SnapMosaicOffset = { x: number; y: number;};export type SnapMosaicBaseProps = { accessibilityLabel?: string; children: ReactNode; completed?: boolean; defaultCompleted?: boolean; disabled?: boolean; onCompletedChange?: (completed: boolean) => void; restingOffset?: SnapMosaicOffset; snapRadius?: number;};export type SnapMosaicSlotProps = Pick< SnapMosaicBaseProps, "children">;snap-mosaic/uniwind/index.tsximport { Children, isValidElement,} from "react";import { View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SnapMosaicBaseProps, SnapMosaicSlotProps,} from "../types";import { useSnapMosaic } from "../use-mosaic";import type { SlotsToClasses } from "#shared/types/slots";import { snapMosaicVariants, type SnapMosaicSlot,} from "./variants";export type SnapMosaicProps = SnapMosaicBaseProps & { className?: string; classNames?: SlotsToClasses<SnapMosaicSlot>;};function SnapMosaicBoard({ children,}: SnapMosaicSlotProps) { return children;}function SnapMosaicPiece({ children,}: SnapMosaicSlotProps) { return children;}function SnapMosaicRoot({ accessibilityLabel = "Complete mosaic", children, className, classNames, completed, defaultCompleted, disabled, onCompletedChange, restingOffset, snapRadius,}: SnapMosaicProps) { const childNodes = Children.toArray(children); const board = childNodes.find( (child) => isValidElement(child) && child.type === SnapMosaicBoard, ); const piece = childNodes.find( (child) => isValidElement(child) && child.type === SnapMosaicPiece, ); const { accessibilityState, gesture, onLayout, pieceAnimatedStyle, targetAnimatedStyle, toggle, } = useSnapMosaic({ completed, defaultCompleted, disabled, onCompletedChange, restingOffset, snapRadius, }); const { container, piece: pieceSlot, target, } = snapMosaicVariants({ disabled }); return ( <View className={container({ className: [className, classNames?.container], })} onLayout={onLayout} > {isValidElement<SnapMosaicSlotProps>(board) && board.props.children} <Animated.View className={target({ className: classNames?.target })} pointerEvents="none" style={targetAnimatedStyle} /> <GestureDetector gesture={gesture}> <Animated.View accessibilityHint="Double tap or drag the piece into the target." accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={accessibilityState} accessible className={pieceSlot({ className: classNames?.piece, })} onAccessibilityTap={toggle} style={pieceAnimatedStyle} > {isValidElement<SnapMosaicSlotProps>(piece) && piece.props.children} </Animated.View> </GestureDetector> </View> );}export const SnapMosaic = Object.assign(SnapMosaicRoot, { Board: SnapMosaicBoard, Piece: SnapMosaicPiece,});snap-mosaic/uniwind/variants.tsimport { tv } from "tailwind-variants";export const snapMosaicSlots = { container: "h-full w-full overflow-hidden rounded-3xl bg-[#17171b]", piece: "absolute left-1/2 top-1/2 -ml-9 -mt-9 size-18 rounded-[18px] shadow-[0_8px_12px_rgba(0,0,0,0.28)]", target: "absolute left-1/2 top-1/2 -ml-8 -mt-8 size-16 rounded-2xl border-2 border-dashed border-white/80",} as const;export type SnapMosaicSlot = keyof typeof snapMosaicSlots;export const snapMosaicVariants = tv({ slots: snapMosaicSlots, variants: { disabled: { true: { container: "opacity-[0.45]", }, }, },});snap-mosaic/use-mosaic.tsimport { useCallback, useMemo, useState,} from "react";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import { Extrapolation, interpolate, runOnJS, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withSpring, withTiming,} from "react-native-reanimated";import { SNAP_MOSAIC_CONFIG } from "./config";import type { SnapMosaicBaseProps } from "./types";function clamp(value: number, minimum: number, maximum: number) { "worklet"; return Math.min(maximum, Math.max(minimum, value));}export function useSnapMosaic({ completed, defaultCompleted = false, disabled = false, onCompletedChange, restingOffset = SNAP_MOSAIC_CONFIG.defaultRestingOffset, snapRadius = SNAP_MOSAIC_CONFIG.defaultSnapRadius,}: Omit< SnapMosaicBaseProps, "accessibilityLabel" | "children">) { if (snapRadius <= 0) { throw new Error( "SnapMosaic requires snapRadius to be greater than zero.", ); } const isControlled = completed !== undefined; const [internalCompleted, setInternalCompleted] = useState(defaultCompleted); const isCompleted = completed ?? internalCompleted; const initialX = isCompleted ? 0 : restingOffset.x; const initialY = isCompleted ? 0 : restingOffset.y; const positionX = useSharedValue(initialX); const positionY = useSharedValue(initialY); const dragStartX = useSharedValue(initialX); const dragStartY = useSharedValue(initialY); const containerWidth = useSharedValue(0); const containerHeight = useSharedValue(0); const isDragging = useSharedValue(false); const pressed = useSharedValue(0); const reducedMotion = useReducedMotion(); const animatedX = useDerivedValue(() => { if (isDragging.get()) { return positionX.get(); } const nextX = isCompleted ? 0 : restingOffset.x; return reducedMotion ? nextX : withSpring(nextX, SNAP_MOSAIC_CONFIG.spring); }); const animatedY = useDerivedValue(() => { if (isDragging.get()) { return positionY.get(); } const nextY = isCompleted ? 0 : restingOffset.y; return reducedMotion ? nextY : withSpring(nextY, SNAP_MOSAIC_CONFIG.spring); }); const commitCompleted = useCallback( (nextCompleted: boolean) => { if (disabled || nextCompleted === isCompleted) { return; } if (!isControlled) { setInternalCompleted(nextCompleted); } onCompletedChange?.(nextCompleted); }, [ disabled, isCompleted, isControlled, onCompletedChange, ], ); const onLayout = useCallback( (event: LayoutChangeEvent) => { containerWidth.set(event.nativeEvent.layout.width); containerHeight.set(event.nativeEvent.layout.height); }, [containerHeight, containerWidth], ); const gesture = useMemo(() => { function settle(nextCompleted: boolean) { "worklet"; const nextX = nextCompleted ? 0 : restingOffset.x; const nextY = nextCompleted ? 0 : restingOffset.y; if (reducedMotion) { positionX.set(nextX); positionY.set(nextY); isDragging.set(false); return; } positionX.set( withSpring(nextX, SNAP_MOSAIC_CONFIG.spring), ); positionY.set( withSpring( nextY, SNAP_MOSAIC_CONFIG.spring, (finished) => { if (finished) { isDragging.set(false); } }, ), ); } const tap = Gesture.Tap() .enabled(!disabled) .onBegin(() => { pressed.set(withTiming(1, { duration: 80 })); }) .onEnd((_event, success) => { if (!success) { return; } const nextCompleted = !isCompleted; isDragging.set(true); settle(nextCompleted); runOnJS(commitCompleted)(nextCompleted); }) .onFinalize(() => { pressed.set(withTiming(0, { duration: 140 })); }); const pan = Gesture.Pan() .enabled(!disabled) .minDistance(3) .onStart(() => { const currentX = animatedX.get(); const currentY = animatedY.get(); positionX.set(currentX); positionY.set(currentY); dragStartX.set(currentX); dragStartY.set(currentY); isDragging.set(true); pressed.set(withTiming(1, { duration: 80 })); }) .onUpdate((event) => { const horizontalLimit = Math.max( 0, (containerWidth.get() - SNAP_MOSAIC_CONFIG.pieceSize) / 2, ); const verticalLimit = Math.max( 0, (containerHeight.get() - SNAP_MOSAIC_CONFIG.pieceSize) / 2, ); positionX.set( clamp( dragStartX.get() + event.translationX, -horizontalLimit, horizontalLimit, ), ); positionY.set( clamp( dragStartY.get() + event.translationY, -verticalLimit, verticalLimit, ), ); }) .onEnd((event) => { const projectedX = positionX.get() + event.velocityX * SNAP_MOSAIC_CONFIG.projectionFactor; const projectedY = positionY.get() + event.velocityY * SNAP_MOSAIC_CONFIG.projectionFactor; const distance = Math.hypot(projectedX, projectedY); const nextCompleted = distance <= snapRadius; settle(nextCompleted); runOnJS(commitCompleted)(nextCompleted); }) .onFinalize((_event, success) => { if (!success) { isDragging.set(false); } pressed.set(withTiming(0, { duration: 140 })); }); return Gesture.Race(pan, tap); }, [ animatedX, animatedY, commitCompleted, containerHeight, containerWidth, disabled, dragStartX, dragStartY, isCompleted, isDragging, positionX, positionY, pressed, reducedMotion, restingOffset.x, restingOffset.y, snapRadius, ]); const pieceAnimatedStyle = useAnimatedStyle(() => { const x = animatedX.get(); const y = animatedY.get(); const distance = Math.hypot(x, y); const proximity = 1 - clamp(distance / snapRadius, 0, 1); return { transform: [ { translateX: x }, { translateY: y }, { rotate: `${interpolate( x, [-120, 0, 120], [-7, 0, 7], Extrapolation.CLAMP, )}deg`, }, { scale: interpolate( pressed.get(), [0, 1], [1, 0.94 + proximity * 0.04], ), }, ], }; }); const targetAnimatedStyle = useAnimatedStyle(() => { const distance = Math.hypot(animatedX.get(), animatedY.get()); const proximity = 1 - clamp(distance / snapRadius, 0, 1); return { opacity: interpolate( proximity, [0, 0.72, 1], [0.32, 0.82, 0], ), transform: [ { scale: interpolate( proximity, [0, 1], [0.9, 1.06], ), }, ], }; }); const toggle = useCallback(() => { commitCompleted(!isCompleted); }, [commitCompleted, isCompleted]); return { accessibilityState: { disabled, selected: isCompleted, }, gesture, isCompleted, onLayout, pieceAnimatedStyle, targetAnimatedStyle, toggle, };}