gestures
Weighty Toggle
A switch with visual mass that compresses on contact and settles with a spring.
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-handlerInstall tailwind variants
pnpm add tailwind-variantsUsage
WeightyToggleExample.tsximport { 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 })} /> );}weighty-toggle/config.tsexport 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.tsexport type WeightyToggleBaseProps = { accessibilityLabel?: string; checked?: boolean; defaultChecked?: boolean; disabled?: boolean; label?: string; onCheckedChange?: (checked: boolean) => void;};weighty-toggle/uniwind/index.tsximport { 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.tsimport { 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.tsimport { 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, };}