How to build type-enforced UI components in React Native using @shopify/restyle

by Ilias Haddad

It’s been quite some time since I wrote a technical post on my blog, and here’s a new one about building type-enforced UI components in React Native with @shopify/restyle and expo.

@shopify/restyle is a powerful styling library for React Native that brings type safety and consistency to your UI components. Unlike traditional styling approaches, Restyle allows you to create a centralized theme configuration that enforces design system principles across your entire application.

Getting Started

Project Setup

  • Setup your react native project using expo
npx create-expo-app@latest
  • Go to your project directory and install @shopify/restyle package using expo
cd /path/to/project
npx expo install @shopify/restyle

Creating Your Theme

Create a theme.tsx file to define your design system:

touch theme.tsx
  • Copy and paste the default theme configuration
import {createTheme} from '@shopify/restyle';
const palette = {
purpleLight: '#8C6FF7',
purplePrimary: '#5A31F4',
purpleDark: '#3F22AB',
greenLight: '#56DCBA',
greenPrimary: '#0ECD9D',
greenDark: '#0A906E',
black: '#0B0B0B',
white: '#F0F2F3',
};
const theme = createTheme({
colors: {
mainBackground: palette.white,
cardPrimaryBackground: palette.purplePrimary,
},
spacing: {
s: 8,
m: 16,
l: 24,
xl: 40,
},
textVariants: {
header: {
fontWeight: 'bold',
fontSize: 34,
},
body: {
fontSize: 16,
lineHeight: 24,
},
defaults: {
// We can define a default text variant here.
},
},
});
export type Theme = typeof theme;
export default theme;

Implementing Theme Provider

Update your app/_layout.tsx:

import { DarkTheme, DefaultTheme } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import "react-native-reanimated";
import { ThemeProvider } from "@shopify/restyle";
import theme from "@/theme";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<ThemeProvider theme={theme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}

Creating Reusable Components

Text Component

touch components/Text.tsx
// In components/Text.tsx
import {createText} from '@shopify/restyle';
import {Theme} from '../theme';
export const Text = createText<Theme>();

Let’s use it in our home screen

import { Text } from "@/components/Text";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
return (
<SafeAreaView>
<Text margin="m" variant="header">
This is the Home screen. Built using @shopify/restyle.
</Text>
</SafeAreaView>
);
}

As you can see in the code above, we’re passing margin as a “m” instead of a number. We’re getting the value from our theme.tsxfile.

// ./theme.tsx
const theme = createTheme({
spacing: {
s: 8,
m: 16, // margin="m"
l: 24,
xl: 40,
},
textVariants: {
header: { // our text header variant
fontWeight: 'bold',
fontSize: 34,
},
body: {
fontSize: 16,
lineHeight: 24,
},
},
// ...rest of code
},
});

This how our the home page view will looks

Skeleton Loader Component

Let’s build a Skeleton Loader card

touch components/SkeletonLoader.tsx
// components/SkeletonLoader.tsx
import {
BackgroundColorProps,
createBox,
createRestyleComponent,
createVariant,
spacing,
SpacingProps,
VariantProps,
} from "@shopify/restyle";
import { Theme } from "@/theme";
import { View } from "react-native";
const Box = createBox<Theme>();
type Props = SpacingProps<Theme> &
VariantProps<Theme, "cardVariants"> &
BackgroundColorProps<Theme> &
React.ComponentProps<typeof View>;
const CardSkeleton = createRestyleComponent<Props, Theme>([
spacing,
createVariant({ themeKey: "cardVariants" }),
]);
const SkeletonLoader = () => {
return (
<CardSkeleton variant="elevated">
<Box
backgroundColor="cardPrimaryBackground"
height={20}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
>
</Box>
<Box
backgroundColor="cardPrimaryBackground"
height={100}
marginBottom="s"
width="90%"
overflow="hidden"
borderRadius={"m"}
>
</Box>
<Box
backgroundColor="cardPrimaryBackground"
height={50}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
>
</Box>
</CardSkeleton>
);
};
export default SkeletonLoader;
  • We create a new box as a predefined component from @shopify/restyle package and this will how us to create the Skeleton Box
const Box = createBox<Theme>();
  • Create a new CardSkeleton component using the createStyleComponent to create a custom component and we passed props which are spacing and cardVariants that we have to define in our theme.tsx file
type Props = SpacingProps<Theme> &
VariantProps<Theme, "cardVariants"> &
BackgroundColorProps<Theme> &
React.ComponentProps<typeof View>;
const CardSkeleton = createRestyleComponent<Props, Theme>([
spacing,
createVariant({ themeKey: "cardVariants" }),
]);
  • Create a SkeletonLoader Component to render our Skelton Card component
// components/SkeletonLoader.tsx
export const SkeletonLoader = () => {
return (
<CardSkeleton variant="elevated">
<Box
backgroundColor="cardPrimaryBackground"
height={20}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
></Box>
<Box
backgroundColor="cardPrimaryBackground"
height={100}
marginBottom="s"
width="90%"
overflow="hidden"
borderRadius={"m"}
></Box>
<Box
backgroundColor="cardPrimaryBackground"
height={50}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
></Box>
</CardSkeleton>
);
};

We have one thing left to make it working, update theme.tsxfile to have cardVariants

const theme = createTheme({
colors: {
// Add Black Color to use it later on
black: palette.black,
},
// Add Border Radius Variants
borderRadii: {
s: 4,
m: 10,
l: 25,
xl: 75,
},
// Add Card Variants
cardVariants: {
elevated: {
shadowColor: "black",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
borderRadius: "m",
},
defaults: {
padding: "m",
borderRadius: "m",
},
},
});

That’s great, but let’s animation to our component

// components/SkeletonLoader.tsx
const ShimmerAnimation = () => {
const shimmerTranslate = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.loop(
Animated.timing(shimmerTranslate, {
toValue: 1,
duration: 1500,
useNativeDriver: true,
})
).start();
}, [shimmerTranslate]);
const translateX = shimmerTranslate.interpolate({
inputRange: [0, 1],
outputRange: [-300, 300],
});
return (
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: 100,
backgroundColor: "rgba(255,255,255,0.2)",
transform: [{ translateX }],
}}
/>
);
};

and let’s use it in our Skeleton Loader Component

// components/SkeletonLoader.tsx
export const SkeletonLoader = () => {
return (
<CardSkeleton variant="elevated">
<Box
backgroundColor="cardPrimaryBackground"
height={20}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
>
<ShimmerAnimation />
</Box>
<Box
backgroundColor="cardPrimaryBackground"
height={100}
marginBottom="s"
width="90%"
overflow="hidden"
borderRadius={"m"}
>
<ShimmerAnimation />
</Box>
<Box
backgroundColor="cardPrimaryBackground"
height={50}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
>
<ShimmerAnimation />
</Box>
</CardSkeleton>
);
};

And here’s the full component code:

// components/SkeletonLoader.tsx
import { useEffect, useRef } from "react";
import { Animated } from "react-native";
import {
BackgroundColorProps,
createBox,
createRestyleComponent,
createVariant,
spacing,
SpacingProps,
VariantProps,
} from "@shopify/restyle";
import { Theme } from "@/theme";
import { View } from "react-native";
const Box = createBox<Theme>();
const ShimmerAnimation = () => {
const shimmerTranslate = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.loop(
Animated.timing(shimmerTranslate, {
toValue: 1,
duration: 1500,
useNativeDriver: true,
})
).start();
}, [shimmerTranslate]);
const translateX = shimmerTranslate.interpolate({
inputRange: [0, 1],
outputRange: [-300, 300],
});
return (
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: 100,
backgroundColor: "rgba(255,255,255,0.2)",
transform: [{ translateX }],
}}
/>
);
};
type Props = SpacingProps<Theme> &
VariantProps<Theme, "cardVariants"> &
BackgroundColorProps<Theme> &
React.ComponentProps<typeof View>;
const CardSkeleton = createRestyleComponent<Props, Theme>([
spacing,
createVariant({ themeKey: "cardVariants" }),
]);
export const SkeletonLoader = () => {
return (
<CardSkeleton variant="elevated">
<Box
backgroundColor="cardPrimaryBackground"
height={20}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
>
<ShimmerAnimation />
</Box>
<Box
backgroundColor="cardPrimaryBackground"
height={100}
marginBottom="s"
width="90%"
overflow="hidden"
borderRadius={"m"}
>
<ShimmerAnimation />
</Box>
<Box
backgroundColor="cardPrimaryBackground"
height={50}
marginBottom="s"
width="70%"
overflow="hidden"
borderRadius={"m"}
>
<ShimmerAnimation />
</Box>
</CardSkeleton>
);
};

Et voila, we made a skeleton loader card using @shopify/restyle using

Support for dark mode

Let’s start with adding dark theme configuration, in your theme.tsxfile

// theme.tsx
export const darkTheme: Theme = {
...theme,
colors: {
...theme.colors,
mainBackground: palette.white,
cardPrimaryBackground: palette.purpleDark,
greenPrimary: palette.purpleLight,
},
textVariants: {
...theme.textVariants,
defaults: {
...theme.textVariants.header,
color: palette.purpleDark,
},
},

Add our dark theme configuration in our app layout by adding it to our layout.tsx file

// app/_layout.tsx
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import "react-native-reanimated";
import { ThemeProvider } from "@shopify/restyle";
import theme, { darkTheme } from "@/theme";
import { useColorScheme } from "react-native";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const colorSchema = useColorScheme();
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<ThemeProvider theme={colorSchema === "dark" ? darkTheme : theme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}
  • Get color schema using useColorScheme hook from react-native
// app/_layout.tsx
import { useColorScheme } from "react-native";
//... rest of the code
const colorSchema = useColorScheme();
  • Based on the color schema, use the default light theme or in dark mode use the darkTheme config defined in theme.tsx file
// app/_layout.tsx
import theme, { darkTheme } from "@/theme";
//... rest of the code
<ThemeProvider theme={colorSchema === "dark" ? darkTheme : theme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>

Here’s it dark and light mode.

Et voila, we managed to create type-enforced UI component using @shopify/restyle package

Thank you :)

  • React Native

Tell me about your project