DEV Community

Ilias Haddad
Ilias Haddad

Posted on

2

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

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
Enter fullscreen mode Exit fullscreen mode
  • Go to your project directory and install @shopify/restyle package using expo
cd /path/to/project
npx expo install @shopify/restyle
Enter fullscreen mode Exit fullscreen mode

Creating Your Theme

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

touch theme.tsx
Enter fullscreen mode Exit fullscreen mode
  • 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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

Creating Reusable Components

Text Component

touch components/Text.tsx
Enter fullscreen mode Exit fullscreen mode
// In components/Text.tsx

import {createText} from '@shopify/restyle';
import {Theme} from '../theme';

export const Text = createText<Theme>();

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

This how our the home page view will looks

Image description

Skeleton Loader Component

Let’s build a Skeleton Loader card

touch components/SkeletonLoader.tsx
Enter fullscreen mode Exit fullscreen mode
// 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;

Enter fullscreen mode Exit fullscreen mode
  • 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>();
Enter fullscreen mode Exit fullscreen mode
  • 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" }),
]);
Enter fullscreen mode Exit fullscreen mode
  • 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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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",
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

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 }],
      }}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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

Simulator Screenshot - iPhone 16 - 2024-11-27 at 14.15.02.png

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,
    },
  },
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode
  • Get color schema using useColorScheme hook from react-native
  // app/_layout.tsx

 import { useColorScheme } from "react-native";

  //... rest of the code

  const colorSchema = useColorScheme();

Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode

Here’s it dark and light mode.

Image description

Image description

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

Thank you :)

Sentry mobile image

Is your mobile app slow? Improve performance with these key strategies.

Improve performance with key strategies like TTID/TTFD & app start analysis.

Read the blog post

Top comments (0)

Sentry mobile image

Improving mobile performance, from slow screens to app start time

Based on our experience working with thousands of mobile developer teams, we developed a mobile monitoring maturity curve.

Read more

👋 Kindness is contagious

DEV works best when you're signed in—unlocking a more customized experience with features like dark mode and personalized reading settings!

Okay