Theme Switch
A Skia based theme switch interaction
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @shopify/react-native-skiaCopy and paste the following code into your project.
component/organisms/theme-switch
import React, { useState, useRef, forwardRef, useImperativeHandle, memo,} from "react";import { Canvas, Circle, Group, Image, Mask, Rect, SkImage, makeImageFromView,} from "@shopify/react-native-skia";import { Dimensions, PixelRatio, StyleSheet, View } from "react-native";import { useSharedValue, withTiming } from "react-native-reanimated";import { ThemeMode, AnimationType, type ThemeSwitcherProps, type ThemeSwitcherRef,} from "./types";import { DEFAULT_ANIMATION_DURATION, DEFAULT_ANIMATION_TYPE, DEFAULT_SWITCH_DELAY, DEFAULT_EASING,} from "./conf";import { wait, getEasingFunction, getMaxRadius } from "./helpers";export const ThemeSwitcher = forwardRef<ThemeSwitcherRef, ThemeSwitcherProps>( ( { theme, onThemeChange, children, animationDuration = DEFAULT_ANIMATION_DURATION, animationType = DEFAULT_ANIMATION_TYPE, style, onAnimationStart, onAnimationComplete, switchDelay = DEFAULT_SWITCH_DELAY, easing = DEFAULT_EASING, }, ref, ) => { const pd = PixelRatio.get(); const viewRef = useRef<View>(null); const [overlay, setOverlay] = useState<SkImage | null>(null); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("screen"); const circleRadius = useSharedValue(0); const circleCenterX = useSharedValue(SCREEN_WIDTH / 2); const circleCenterY = useSharedValue(SCREEN_HEIGHT / 2); const wipePosition = useSharedValue(0); const [isAnimating, setIsAnimating] = useState(false); const animateThemeChange = async <T extends number, U extends number>( touchX?: T, touchY?: U, ): Promise<void> => { if (isAnimating) return; setIsAnimating(true); onAnimationStart?.(); const centerX = touchX ?? SCREEN_WIDTH / 2; const centerY = touchY ?? SCREEN_HEIGHT / 2; circleCenterX.value = centerX; circleCenterY.value = centerY; if (viewRef.current) { const snapshot = await makeImageFromView<View>(viewRef); setOverlay(snapshot); } await wait<number>(switchDelay); const newTheme: ThemeMode = theme === ThemeMode.Dark ? ThemeMode.Light : ThemeMode.Dark; onThemeChange(newTheme); const easingFn = getEasingFunction(easing); const animationTypeValue = typeof animationType === "string" ? animationType : animationType; switch (animationTypeValue) { case AnimationType.Circular: case "circular": { const maxRadius = getMaxRadius( centerX, centerY, SCREEN_WIDTH, SCREEN_HEIGHT, ); circleRadius.value = withTiming(maxRadius, { duration: animationDuration, easing: easingFn, }); break; } case AnimationType.CircularInverted: case "circularInverted": { const maxRadiusInverted = getMaxRadius( centerX, centerY, SCREEN_WIDTH, SCREEN_HEIGHT, ); circleRadius.value = maxRadiusInverted; circleRadius.value = withTiming(0, { duration: animationDuration, easing: easingFn, }); break; } case AnimationType.Wipe: case "wipe": wipePosition.value = withTiming(SCREEN_WIDTH, { duration: animationDuration, easing: easingFn, }); break; case AnimationType.WipeRight: case "wipeRight": wipePosition.value = SCREEN_WIDTH; wipePosition.value = withTiming(0, { duration: animationDuration, easing: easingFn, }); break; case AnimationType.WipeDown: case "wipeDown": wipePosition.value = withTiming(SCREEN_HEIGHT, { duration: animationDuration, easing: easingFn, }); break; case AnimationType.WipeUp: case "wipeUp": wipePosition.value = SCREEN_HEIGHT; wipePosition.value = withTiming(0, { duration: animationDuration, easing: easingFn, }); break; default: wipePosition.value = withTiming(SCREEN_WIDTH, { duration: animationDuration, easing: easingFn, }); } await wait(animationDuration); setOverlay(null); setIsAnimating(false); onAnimationComplete?.(); await wait(200); circleRadius.value = 0; wipePosition.value = 0; }; useImperativeHandle(ref, () => ({ animate: animateThemeChange, })); const renderMask = () => { const animationTypeValue = animationType as string; switch (animationTypeValue) { case AnimationType.Circular: case "circular": return ( <Group> <Rect height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="white" /> <Circle cx={circleCenterX} cy={circleCenterY} r={circleRadius} color="black" /> </Group> ); case AnimationType.CircularInverted: case "circularInverted": return ( <Group> <Circle cx={circleCenterX} cy={circleCenterY} r={circleRadius} color="white" /> </Group> ); case AnimationType.Wipe: case "wipe": return ( <Group> <Rect height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="white" /> <Rect height={SCREEN_HEIGHT} width={wipePosition} color="black" /> </Group> ); case AnimationType.WipeRight: case "wipeRight": return ( <Group> <Rect height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="white" /> <Rect x={wipePosition} height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="black" /> </Group> ); case AnimationType.WipeDown: case "wipeDown": return ( <Group> <Rect height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="white" /> <Rect height={wipePosition} width={SCREEN_WIDTH} color="black" /> </Group> ); case AnimationType.WipeUp: case "wipeUp": return ( <Group> <Rect height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="white" /> <Rect y={wipePosition} height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="black" /> </Group> ); default: return ( <Group> <Rect height={SCREEN_HEIGHT} width={SCREEN_WIDTH} color="white" /> <Rect height={SCREEN_HEIGHT} width={wipePosition} color="black" /> </Group> ); } }; return ( <View style={[styles.container, style]} ref={viewRef} collapsable={false}> {children} {overlay && ( <Canvas style={StyleSheet.absoluteFillObject} pointerEvents="none"> <Mask mode="luminance" mask={renderMask()}> <Image image={overlay} x={0} y={0} width={overlay.width() / pd} height={overlay.height() / pd} /> </Mask> </Canvas> )} </View> ); },);const styles = StyleSheet.create({ container: { flex: 1, },});Usage
import React from "react";import { StyleSheet, Text, View, Pressable } from "react-native";import { StatusBar } from "expo-status-bar";import { SymbolView } from "expo-symbols";import { AnimationType, useTheme } from "@/components/organisms/theme-switch";export default function HomeScreen() { const { colors, toggleTheme, isDark } = useTheme(); return ( <> <StatusBar animated style={isDark ? "light" : "dark"} /> <View style={[styles.screen, { backgroundColor: colors.background }]}> {/* Center Content */} <View style={styles.center}> <SymbolView name="circle.lefthalf.filled" size={28} tintColor={colors.text} /> <Text style={[styles.title, { color: colors.text }]}> Theme Switch </Text> <Text style={[styles.subtitle, { color: colors.textSecondary }]}> Tap to toggle appearance </Text> </View> {/* Floating Toggle */} <Pressable style={[ styles.fab, { backgroundColor: colors.card, borderColor: colors.border, }, ]} onPress={(e) => toggleTheme({ animationType: isDark ? AnimationType.CircularInverted : AnimationType.Circular, touchX: e.nativeEvent.pageX, touchY: e.nativeEvent.pageY, }) } > <SymbolView name={isDark ? "sun.max.fill" : "moon.fill"} tintColor={colors.text} size={20} /> </Pressable> </View> </> );}const styles = StyleSheet.create({ screen: { flex: 1, }, center: { flex: 1, alignItems: "center", gap: 10, top: 180, }, title: { fontSize: 26, fontWeight: "600", letterSpacing: -0.3, }, subtitle: { fontSize: 14, }, fab: { position: "absolute", top: 62, left: 20, width: 48, height: 48, borderRadius: 24, justifyContent: "center", alignItems: "center", borderWidth: StyleSheet.hairlineWidth, },});Props
AnimationType
ThemeSwitcherProps
ThemeProviderProps
React Native Reanimated
React Native Skia
