ThemeToggle
A dark mode toggle with a seamless circular reveal transition. Uses react-native-view-shot for screenshots and Reanimated for smooth animation.
9:41
signal_cellular_altwifibattery_full
☀️ Light
Tap anywhere to toggle
Installation
$
Usage
Example.tsx
import { ThemeProvider, useTheme } from './components/ThemeToggle';import { Pressable, Text, View } from 'react-native';function Main() {const { colorScheme, toggleTheme } = useTheme();const bg = colorScheme === 'dark' ? '#111' : '#fff';const fg = colorScheme === 'dark' ? '#fff' : '#000';return (<View style={{ flex: 1, backgroundColor: bg, justifyContent: 'center', alignItems: 'center' }}><PressableonPress={(e) => toggleTheme(e.nativeEvent.pageX, e.nativeEvent.pageY)}style={{ padding: 16, backgroundColor: colorScheme === 'dark' ? '#333' : '#eee', borderRadius: 8 }}><Text style={{ color: fg }}>{colorScheme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode'}</Text></Pressable></View>);}export default function App() {return (<ThemeProvider defaultTheme="light"><Main /></ThemeProvider>);}
Full Component Code
ThemeToggle.tsx
1import React, { createContext, useContext, useRef, useState, useMemo, useCallback, ReactNode } from 'react';2import { View, StyleSheet, Dimensions, ImageBackground } from 'react-native';3import { captureRef } from 'react-native-view-shot';4import Animated, { useSharedValue, useAnimatedStyle, withTiming, runOnJS, Easing } from 'react-native-reanimated';56type ColorScheme = 'light' | 'dark';78interface ThemeContextType {9 colorScheme: ColorScheme;10 toggleTheme: (x: number, y: number) => void;11}1213const ThemeContext = createContext<ThemeContextType | null>(null);1415export const useTheme = () => {16 const context = useContext(ThemeContext);17 if (!context) throw new Error('useTheme must be used within ThemeProvider');18 return context;19};2021const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));22const { width, height } = Dimensions.get('window');2324const getMaxRadius = (x: number, y: number) => {25 const corners = [{ x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }];26 return Math.max(...corners.map(c => Math.sqrt(Math.pow(c.x - x, 2) + Math.pow(c.y - y, 2))));27};2829export const ThemeProvider = ({ children, defaultTheme = 'light' }: { children: ReactNode; defaultTheme?: ColorScheme }) => {30 const [colorScheme, setColorScheme] = useState<ColorScheme>(defaultTheme);31 const viewRef = useRef<View>(null);32 const [oldThemeImage, setOldThemeImage] = useState<string | null>(null);33 const [newThemeImage, setNewThemeImage] = useState<string | null>(null);34 const [active, setActive] = useState(false);35 const [touchPoint, setTouchPoint] = useState({ x: width / 2, y: height / 2 });36 const [maxRadius, setMaxRadius] = useState(0);37 const scale = useSharedValue(0);3839 const animatedCircleStyle = useAnimatedStyle(() => {40 const r = maxRadius * scale.value;41 return { width: r * 2, height: r * 2, borderRadius: r, left: touchPoint.x - r, top: touchPoint.y - r };42 });4344 const animatedImageStyle = useAnimatedStyle(() => {45 const r = maxRadius * scale.value;46 return { left: -(touchPoint.x - r), top: -(touchPoint.y - r), width, height };47 });4849 const finishTransition = useCallback(() => {50 setOldThemeImage(null);51 setNewThemeImage(null);52 setActive(false);53 }, []);5455 const toggleTheme = useCallback(async (x: number, y: number) => {56 if (active) return;57 scale.value = 0;58 setActive(true);59 setTouchPoint({ x, y });60 setMaxRadius(getMaxRadius(x, y));61 const newScheme = colorScheme === 'light' ? 'dark' : 'light';6263 const oldUri = await captureRef(viewRef, { format: 'png', quality: 0.8, result: 'tmpfile' });64 setOldThemeImage(oldUri);65 await wait(20);66 setColorScheme(newScheme);67 await wait(50);68 const newUri = await captureRef(viewRef, { format: 'png', quality: 0.8, result: 'tmpfile' });69 setNewThemeImage(newUri);7071 scale.value = withTiming(1, { duration: 650, easing: Easing.inOut(Easing.ease) }, (done) => {72 if (done) runOnJS(finishTransition)();73 });74 }, [active, colorScheme, finishTransition, scale]);7576 return (77 <ThemeContext.Provider value={useMemo(() => ({ colorScheme, toggleTheme }), [colorScheme, toggleTheme])}>78 <View style={{ flex: 1 }} ref={viewRef} collapsable={false}>{children}</View>79 {oldThemeImage && (80 <View style={[StyleSheet.absoluteFill, { zIndex: 999999 }]} pointerEvents="none">81 <ImageBackground source={{ uri: oldThemeImage }} style={StyleSheet.absoluteFill} fadeDuration={0} />82 {newThemeImage && (83 <Animated.View style={[{ position: 'absolute', overflow: 'hidden' }, animatedCircleStyle]}>84 <Animated.View style={animatedImageStyle}>85 <ImageBackground source={{ uri: newThemeImage }} style={{ width, height }} fadeDuration={0} />86 </Animated.View>87 </Animated.View>88 )}89 </View>90 )}91 </ThemeContext.Provider>92 );93};
Props
| Name | Type | Default | Description |
|---|
