logoChisa UI

Command Palette

Search for a command to run...

Componentschevron_rightThemeToggle

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' }}>
<Pressable
onPress={(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';
5
6type ColorScheme = 'light' | 'dark';
7
8interface ThemeContextType {
9 colorScheme: ColorScheme;
10 toggleTheme: (x: number, y: number) => void;
11}
12
13const ThemeContext = createContext<ThemeContextType | null>(null);
14
15export const useTheme = () => {
16 const context = useContext(ThemeContext);
17 if (!context) throw new Error('useTheme must be used within ThemeProvider');
18 return context;
19};
20
21const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
22const { width, height } = Dimensions.get('window');
23
24const 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};
28
29export 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);
38
39 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 });
43
44 const animatedImageStyle = useAnimatedStyle(() => {
45 const r = maxRadius * scale.value;
46 return { left: -(touchPoint.x - r), top: -(touchPoint.y - r), width, height };
47 });
48
49 const finishTransition = useCallback(() => {
50 setOldThemeImage(null);
51 setNewThemeImage(null);
52 setActive(false);
53 }, []);
54
55 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';
62
63 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);
70
71 scale.value = withTiming(1, { duration: 650, easing: Easing.inOut(Easing.ease) }, (done) => {
72 if (done) runOnJS(finishTransition)();
73 });
74 }, [active, colorScheme, finishTransition, scale]);
75
76 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

NameTypeDefaultDescription