In most React Native apps, tapping a theme toggle causes the colors to flip instantly. When every detail matters and the user experience has to feel premium, that abrupt change is worth fixing.
The approach that works is to overlay a snapshot of the current view while the theme swaps underneath. Skia's makeImageFromView makes this work in Expo Go without a prebuild.
I built react-native-theme-transition to handle this pattern. The rest of this post covers installation, per-call options, integration with Expo Router, system appearance, and persistence, and closes with how the engine works.
If you use a coding agent, you can install the library's skill and the agent will handle the install, babel config, provider wrap, and sample usage.
npx skills add https://skills.sh/marioprieta/skills/react-native-theme-transition
Install
npx expo install react-native-theme-transition @shopify/react-native-skia react-native-worklets
Add react-native-worklets/plugin as the last plugin in babel.config.js. On Expo SDK 55+ you don't need react-native-reanimated/plugin, since babel-preset-expo already includes it.
Minimal setup
Define your themes in one file:
// theme.ts
import { createThemeTransition } from 'react-native-theme-transition'
export const { ThemeTransitionProvider, useTheme } = createThemeTransition({
themes: {
light: { background: '#ffffff', text: '#111111', primary: '#007AFF' },
dark: { background: '#0b1120', text: '#f9fafb', primary: '#60a5fa' },
},
})
Wrap your app:
// App.tsx
import { ThemeTransitionProvider } from './theme'
export default function App() {
return (
<ThemeTransitionProvider initialTheme="light">
<MyApp />
</ThemeTransitionProvider>
)
}
Toggle the theme with one of the nine transitions:
import { useTheme } from './theme'
function MyScreen() {
const { theme, setTheme } = useTheme()
const next = theme.name === 'dark' ? 'light' : 'dark'
return (
<View style={{ backgroundColor: theme.colors.background }}>
<Pressable onPress={() => setTheme(next, { transition: 'circularReveal' })}>
<Text style={{ color: theme.colors.text }}>Toggle</Text>
</Pressable>
</View>
)
}
That is the smallest working setup. Try it on Snack (iOS) to see all nine transitions live. For React Native CLI setups or edge-case babel configs, see the installation docs.
What you can tweak
Every setTheme call takes a transition-specific options object. Shared across all transitions: duration, easing, animated. Per transition:
circularReveal, heart, star accept origin (a view ref or {x, y}) and inverted
wipe and slide take a direction
split takes a mode and inverted
pixelize and dissolve take shader parameters
Example, using circularReveal from the tap target:
const ref = useRef<View>(null)
<Pressable
ref={ref}
onPress={() => setTheme(next, {
transition: 'circularReveal',
origin: ref,
duration: 500,
easing: Easing.out(Easing.cubic),
})}
>
<Text>Toggle</Text>
</Pressable>
TypeScript narrows the options to the fields valid for the chosen transition, so you cannot pass blockSize to fade by accident. The API reference has the full list per transition.
Wiring it up
useTheme() returns:
const { theme, preference } = useTheme()
// theme.name → your theme keys, inferred (e.g. 'light' | 'dark' | 'ocean')
// theme.colors → Record<Tokens, string>, typed from your themes
// theme.scheme → 'light' | 'dark' (binary classifier)
// preference → theme name or 'system' (the user's raw pick)
The themes object accepts any keys (e.g. light/dark, or default/sepia/night). TypeScript narrows setTheme(name, ...) to the keys you passed.
Use theme.colors for styles and theme.scheme === 'dark' wherever a binary flag is expected (React Navigation's dark prop, UI libraries like Tamagui or Gluestack).
For follow-the-OS defaults, pass systemThemeMap (mapping system light/dark to two of your theme keys) and set initialTheme="system". To persist, save preference to storage and pass it as initialTheme on next launch.
The recipes cover persistence with AsyncStorage, MMKV, and Zustand, plus React Navigation and Expo Router integrations.
How it works
Four steps run in sequence on every theme swap:
1. Capture → makeImageFromView() returns an SkImage of the current view
2. Overlay → mount the SkImage on a permanently-mounted Skia Canvas
3. Swap → re-render the tree underneath with the new theme tokens
4. Animate → fade, reveal, or shader the overlay away
The overlay hides the color swap in step 3 from the user's eye. The animation in step 4 is what the user actually perceives as the transition. The Skia Canvas stays mounted between transitions with a null image, so there's no remount flash.
The engine is ~600 lines in transitionEngine.tsx if you want to read the full implementation.
Next steps
The snapshot-overlay-swap-animate technique works beyond themes. Any time you need to animate between two full tree states (route transitions, onboarding flows, settings panels), the same approach applies.
Repo and issues: github.com/marioprieta/react-native-theme-transition
Live demo: Snack on iOS (Android Snack preview runs slow; real Android devices are fine)
Full docs: react-native-theme-transition.vercel.app
Top comments (0)