UI and Theming
How we manage the UI and theming of the application.
For the past few years, we have tried multiple approaches to style React Native apps: Stylesheet API, styled-components, restyle, and more.
Right now, we are confident that using Uniwind with React Native is the right solution. Uniwind provides a TailwindCSS-like experience specifically optimized for React Native.
If you are familiar with TailwindCSS on the web, you’ll find Uniwind very easy to use, with a similar utility-first approach adapted for React Native’s constraints.
Uniwind was a natural choice, considering that many developers come from a web background and have experience with TailwindCSS.
About Uniwind
Section titled “About Uniwind”Uniwind is a utility-first styling library for React Native that provides a TailwindCSS-like experience. It uses the className prop to apply styles directly to React Native components, making it intuitive for developers familiar with TailwindCSS on the web.
Uniwind is designed specifically for React Native and works seamlessly with modern React Native architecture, including the new architecture and Expo.
For more details about Uniwind, check the official documentation.
Here is an example of how your component should look like:
import { Link } from 'expo-router';import React from 'react';
import type { Post } from '@/api';import { Image, Pressable, Text, View } from '@/components/ui';
type Props = Post;
const images = [ 'https://images.unsplash.com/photo-1489749798305-4fea3ae63d43?auto=format&fit=crop&w=800&q=80', 'https://images.unsplash.com/photo-1564507004663-b6dfb3c824d5?auto=format&fit=crop&w=800&q=80', 'https://images.unsplash.com/photo-1515386474292-47555758ef2e?auto=format&fit=crop&w=800&q=80', 'https://plus.unsplash.com/premium_photo-1666815503002-5f07a44ac8fb?auto=format&fit=crop&w=800&q=80', 'https://images.unsplash.com/photo-1587974928442-77dc3e0dba72?auto=format&fit=crop&w=800&q=80',];
export const Card = ({ title, body, id }: Props) => { return ( <Link href={`/feed/${id}`} asChild> <Pressable> <View className="m-2 overflow-hidden rounded-xl border border-neutral-300 bg-white dark:bg-neutral-900"> <Image className="h-56 w-full overflow-hidden rounded-t-xl" contentFit="cover" source={{ uri: images[Math.floor(Math.random() * images.length)], }} />
<View className="p-2"> <Text className="py-3 text-2xl">{title}</Text> <Text numberOfLines={3} className="leading-snug text-gray-600"> {body} </Text> </View> </View> </Pressable> </Link> );};Configuration
Section titled “Configuration”Uniwind follows TailwindCSS configuration patterns, allowing you to customize themes and colors to match your design system.
Like TailwindCSS, you can configure Uniwind through your tailwind.config.js file, customizing colors, spacing, typography, and more.
We have created a ui/theme folder where you can find our custom colors that have been imported into tailwind.config.js and used as a theme for our demo application. You can add your own color palette and use them in your components with Tailwind class names.
You can read more about how to configure your project with Tailwind CSS.
Dark Mode
Section titled “Dark Mode”Why dark mode?
Section titled “Why dark mode?”Dark mode has gained significant traction in recent years and has become an expected feature to have. By applying dark mode, it makes it easier on the eyes in low-light environments and reduces eye strain, which means more time spent on your app.
This template comes with dark mode support out of the box, and it’s very easy to customize the color scheme of your app. Thanks to TailwindCSS dark mode
Implementation
Section titled “Implementation”Since we’re using Uniwind (which follows TailwindCSS patterns) and expo-router, we let them handle the application of theme, and we just take care of the colors we want.
We set the colors in ui/theme/colors.js and we use them in our hook useThemeConfig.tsx to get the theme object that we pass to ThemeProvider directly. For more information check out expo-router
import React, { createContext, useCallback, useContext, useMemo } from 'react';import { Uniwind, useUniwind } from 'uniwind';
export type ThemeName = 'light' | 'dark';
interface AppThemeContextType { currentTheme: string; isLight: boolean; isDark: boolean; setTheme: (theme: ThemeName) => void; toggleTheme: () => void;}
const AppThemeContext = createContext<AppThemeContextType | undefined>( undefined);
export const AppThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children,}) => { const { theme } = useUniwind();
const isLight = useMemo(() => { return theme === 'light' || theme.endsWith('-light'); }, [theme]);
const isDark = useMemo(() => { return theme === 'dark' || theme.endsWith('-dark'); }, [theme]);
const setTheme = useCallback((newTheme: ThemeName) => { Uniwind.setTheme(newTheme); }, []);
const toggleTheme = useCallback(() => { switch (theme) { case 'light': Uniwind.setTheme('dark'); break; case 'dark': Uniwind.setTheme('light'); break; } }, [theme]);
const value = useMemo( () => ({ currentTheme: theme, isLight, isDark, setTheme, toggleTheme, }), [theme, isLight, isDark, setTheme, toggleTheme] );
return ( <AppThemeContext.Provider value={value}> {children} </AppThemeContext.Provider> );};
export const useAppTheme = () => { const context = useContext(AppThemeContext); if (!context) { throw new Error('useAppTheme must be used within AppThemeProvider'); } return context;};// Import global CSS fileimport '../../global.css';
import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, useFonts,} from '@expo-google-fonts/inter';import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';import { Stack } from 'expo-router';import * as SplashScreen from 'expo-splash-screen';import { HeroUINativeProvider } from 'heroui-native';import React, { useCallback } from 'react';import { KeyboardAvoidingView, StyleSheet } from 'react-native';import FlashMessage from 'react-native-flash-message';import { GestureHandlerRootView } from 'react-native-gesture-handler';import { KeyboardProvider } from 'react-native-keyboard-controller';import { configureReanimatedLogger, ReanimatedLogLevel,} from 'react-native-reanimated';
import { APIProvider } from '@/api';import { hydrateAuth } from '@/lib';import { AppThemeProvider } from '@/lib/contexts/app-theme-context';
export { ErrorBoundary } from 'expo-router';
configureReanimatedLogger({ level: ReanimatedLogLevel.warn, strict: false,});
export const unstable_settings = { initialRouteName: '(app)',};
hydrateAuth();// Prevent the splash screen from auto-hiding before asset loading is complete.SplashScreen.preventAutoHideAsync();// Set the animation options. This is optional.SplashScreen.setOptions({ duration: 500, fade: true,});
export default function RootLayout() { const fonts = useFonts({ Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, });
if (!fonts) { return null; }
return ( <Providers> <Stack> <Stack.Screen name="(app)" options={{ headerShown: false }} /> <Stack.Screen name="onboarding" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} /> </Stack> </Providers> );}
const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => { const contentWrapper = useCallback( (children: React.ReactNode) => ( <KeyboardAvoidingView pointerEvents="box-none" behavior="padding" keyboardVerticalOffset={12} className="flex-1" > {children} </KeyboardAvoidingView> ), [] );
return ( <GestureHandlerRootView style={styles.container}> <KeyboardProvider> <AppThemeProvider> <HeroUINativeProvider config={{ toast: { contentWrapper, }, }} > <APIProvider> <BottomSheetModalProvider> {children} <FlashMessage position="top" /> </BottomSheetModalProvider> </APIProvider> </HeroUINativeProvider> </AppThemeProvider> </KeyboardProvider> </GestureHandlerRootView> );};
const styles = StyleSheet.create({ container: { flex: 1, },});How do we handle theme changes?
Section titled “How do we handle theme changes?”We use the loadSelectedTheme function to load the theme from the storage if there’s a theme saved in the storage, otherwise, we let Uniwind use the default theme (system).
To set the selected theme, we use the useSelectedTheme hook, which sets the theme in the storage and updates the color scheme of the app.
import React from 'react';import { useMMKVString } from 'react-native-mmkv';
import { type ThemeName, useAppTheme } from '../contexts/app-theme-context';import { storage } from '../storage';
const SELECTED_THEME = 'SELECTED_THEME';
/** * this hooks should only be used while selecting the theme * This hooks will return the selected theme which is stored in MMKV * selectedTheme should be one of the following values 'light', 'dark' * don't use this hooks if you want to use it to style your component based on the theme use useAppTheme from uniwind instead * */export const useSelectedTheme = () => { const { setTheme } = useAppTheme();
const [theme, _setTheme] = useMMKVString(SELECTED_THEME, storage);
const setSelectedTheme = React.useCallback( (t: ThemeName) => { setTheme(t); _setTheme(t); }, [setTheme, _setTheme] );
const selectedTheme = (theme ?? 'system') as ThemeName; return { selectedTheme, setSelectedTheme } as const;};Add dark mode for each component
Section titled “Add dark mode for each component”To add the values for the light mode, you can simply write them directly in your component class. For the dark mode, you can use the dark: variant.
<View className="... border-neutral-200 dark:border-yellow-700">....</View>If you want to use the style prop, you can use the useColorScheme hook to get the current color scheme and use it to apply the desired style. However, in most cases, you won’t need it as the dark: variant will do the job.
import { useColorScheme } from 'react-native';
const colorScheme = useColorScheme();const style = colorScheme === 'dark' ? { backgroundColor: 'black' } : { backgroundColor: 'white' };For more details about dark mode, you can check the TailwindCSS dark mode documentation and Uniwind documentation