Veltrix

gestures

Snap Mosaic

A displaced mosaic piece that reacts to proximity and snaps into a valid target.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedworkletsgesture handler
GitHubOpen in GitHub

Installation

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

pnpm add react-native-reanimated react-native-worklets react-native-gesture-handler

Anatomy

1 file

anatomy.tsx
<SnapMosaic>  <SnapMosaic.Board />  <SnapMosaic.Piece /></SnapMosaic>

Install tailwind variants

pnpm add tailwind-variants

Usage

SnapMosaicExample.tsx
import { 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>  );}

Component files

5 files

snap-mosaic/config.ts
export 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.ts
import 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.tsx
import {  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.ts
import { 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.ts
import {  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,  };}

API reference

Generated directly from the exported component props.

SnapMosaic

SnapMosaic.Board

SnapMosaic.Piece