Veltrix

gestures

Weighty Toggle

A switch with visual mass that compresses on contact and settles with a spring.

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

Install tailwind variants

pnpm add tailwind-variants

Usage

WeightyToggleExample.tsx
import { WeightyToggle } from "@animations/ui/components/gestures/weighty-toggle/uniwind";export function WeightyToggleExample() {  return (    <WeightyToggle      className="w-full"      defaultChecked      label="Focus mode"      onCheckedChange={(checked) => console.log({ checked })}    />  );}

Component files

5 files

weighty-toggle/config.ts
export const WEIGHTY_TOGGLE_CONFIG = {  knobSize: 28,  trackPadding: 4,  trackWidth: 64,  spring: {    damping: 15,    mass: 0.8,    stiffness: 190,  },} as const;export const WEIGHTY_TOGGLE_TRAVEL =  WEIGHTY_TOGGLE_CONFIG.trackWidth -  WEIGHTY_TOGGLE_CONFIG.knobSize -  WEIGHTY_TOGGLE_CONFIG.trackPadding * 2;
weighty-toggle/types.ts
export type WeightyToggleBaseProps = {  accessibilityLabel?: string;  checked?: boolean;  defaultChecked?: boolean;  disabled?: boolean;  label?: string;  onCheckedChange?: (checked: boolean) => void;};
weighty-toggle/uniwind/index.tsx
import { Text, View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { useWeightyToggle } from "../use-toggle";import type { WeightyToggleBaseProps } from "../types";import {  weightyToggleVariants,  type WeightyToggleSlot,} from "./variants";export type WeightyToggleProps = WeightyToggleBaseProps & {  className?: string;  classNames?: SlotsToClasses<WeightyToggleSlot>;};export function WeightyToggle({  accessibilityLabel,  checked,  className,  classNames,  defaultChecked,  disabled,  label,  onCheckedChange,}: WeightyToggleProps) {  const {    accessibilityState,    gesture,    isChecked,    knobAnimatedStyle,    toggle,  } = useWeightyToggle({    checked,    defaultChecked,    disabled,    onCheckedChange,  });  const { container, knob, label: labelSlot, track } =    weightyToggleVariants({      checked: isChecked,      disabled,    });  return (    <GestureDetector gesture={gesture}>      <View        accessibilityLabel={accessibilityLabel ?? label}        accessibilityRole="switch"        accessibilityState={accessibilityState}        accessible        className={container({          className: [className, classNames?.container],        })}        onAccessibilityTap={toggle}      >        {label && (          <Text            className={labelSlot({              className: classNames?.label,            })}          >            {label}          </Text>        )}        <View className={track({ className: classNames?.track })}>          <Animated.View            className={knob({ className: classNames?.knob })}            style={knobAnimatedStyle}          />        </View>      </View>    </GestureDetector>  );}
weighty-toggle/uniwind/variants.ts
import { tv } from "tailwind-variants";export const weightyToggleSlots = {  container: "flex-row items-center gap-3.5",  knob:    "size-7 rounded-full bg-white shadow-[0_3px_8px_rgba(0,0,0,0.22)]",  label: "flex-1 text-base font-bold text-[#272722]",  track: "w-16 justify-center rounded-full p-1",} as const;export type WeightyToggleSlot = keyof typeof weightyToggleSlots;export const weightyToggleVariants = tv({  slots: weightyToggleSlots,  variants: {    checked: {      false: {        track: "bg-[#d6d6ce]",      },      true: {        track: "bg-[#171713]",      },    },    disabled: {      true: {        container: "opacity-[0.45]",      },    },  },});
weighty-toggle/use-toggle.ts
import { useCallback, useMemo, useState } from "react";import { Gesture } from "react-native-gesture-handler";import {  interpolate,  runOnJS,  useAnimatedStyle,  useDerivedValue,  useReducedMotion,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import {  WEIGHTY_TOGGLE_CONFIG,  WEIGHTY_TOGGLE_TRAVEL,} from "./config";import type { WeightyToggleBaseProps } from "./types";export function useWeightyToggle({  checked,  defaultChecked = false,  disabled = false,  onCheckedChange,}: WeightyToggleBaseProps) {  const isControlled = checked !== undefined;  const [internalChecked, setInternalChecked] = useState(defaultChecked);  const isChecked = checked ?? internalChecked;  const progress = useSharedValue(isChecked ? 1 : 0);  const dragStartProgress = useSharedValue(isChecked ? 1 : 0);  const isDragging = useSharedValue(false);  const pressed = useSharedValue(0);  const reducedMotion = useReducedMotion();  const animatedProgress = useDerivedValue(() => {    if (isDragging.get()) {      return progress.get();    }    const nextProgress = isChecked ? 1 : 0;    return reducedMotion      ? nextProgress      : withSpring(nextProgress, WEIGHTY_TOGGLE_CONFIG.spring);  });  const setChecked = useCallback((nextChecked: boolean) => {    isDragging.set(false);    if (disabled || nextChecked === isChecked) {      return;    }    if (!isControlled) {      setInternalChecked(nextChecked);    }    onCheckedChange?.(nextChecked);  }, [    disabled,    isChecked,    isControlled,    isDragging,    onCheckedChange,  ]);  const toggle = useCallback(() => {    setChecked(!isChecked);  }, [isChecked, setChecked]);  const gesture = useMemo(() => {    const tap = Gesture.Tap()      .enabled(!disabled)      .onBegin(() => {        pressed.set(withTiming(1, { duration: 90 }));      })      .onFinalize(() => {        pressed.set(withTiming(0, { duration: 160 }));      })      .onEnd((_event, success) => {        if (success) {          runOnJS(toggle)();        }      });    const pan = Gesture.Pan()      .enabled(!disabled)      .minDistance(3)      .onStart(() => {        const currentProgress = animatedProgress.get();        progress.set(currentProgress);        dragStartProgress.set(currentProgress);        isDragging.set(true);        pressed.set(withTiming(1, { duration: 90 }));      })      .onUpdate((event) => {        const nextProgress =          dragStartProgress.get() +          event.translationX / WEIGHTY_TOGGLE_TRAVEL;        progress.set(Math.min(1, Math.max(0, nextProgress)));      })      .onEnd((event) => {        const nextChecked =          Math.abs(event.velocityX) > 300            ? event.velocityX > 0            : progress.get() >= 0.5;        const nextProgress = nextChecked ? 1 : 0;        progress.set(nextProgress);        runOnJS(setChecked)(nextChecked);      })      .onFinalize((_event, success) => {        if (!success) {          isDragging.set(false);        }        pressed.set(withTiming(0, { duration: 160 }));      });    return Gesture.Race(pan, tap);  }, [    animatedProgress,    disabled,    dragStartProgress,    isDragging,    pressed,    progress,    setChecked,    toggle,  ]);  const knobAnimatedStyle = useAnimatedStyle(() => ({    transform: [      {        translateX: interpolate(          animatedProgress.get(),          [0, 1],          [0, WEIGHTY_TOGGLE_TRAVEL],        ),      },      {        scale: interpolate(pressed.get(), [0, 1], [1, 0.86]),      },      {        rotate: `${interpolate(          animatedProgress.get(),          [0, 1],          [-8, 8],        )}deg`,      },    ],  }));  return {    accessibilityState: {      checked: isChecked,      disabled,    },    gesture,    isChecked,    knobAnimatedStyle,    toggle,  };}

API reference

Generated directly from the exported component props.

WeightyToggle