DEV Community

Cover image for Step-by-Step Guide: Implementing Snap Horizontal Scroll in React Native
Christopher Alves
Christopher Alves

Posted on • Updated on

Step-by-Step Guide: Implementing Snap Horizontal Scroll in React Native

The focus of this article is to assist you in creating a page with a horizontally scrollable component in snap style and a header that reflects the changes between the steps.

This structure is very useful for building wizard forms. It's a very useful form type where the user is guided to fill out the form through steps.

Article in Portuguese(🇧🇷) by clicking here

Stacks

In this project, we will use the following technologies:

  • React Native and Expo - For creating and sharing the application, running natively on Android and iOS.
  • React Native Responsive Font Size - For standardizing font sizes on Android and iOS devices.

Structure

Since the purpose of this article is to build content with horizontally scrollable snap-style steps, I won't focus on styling other components.

The initial structure has been divided into 2 parts:

  • Header
  • Content Container

Header

Responsible for indicating which steps have been completed and which ones are yet to be finished.

Content Container

Responsible for wrapping up all the content steps and horizontal snap scrolling.

Note: For standardizing font sizes on various devices, we will use the react-native-fontsize library.

I created a utils file that contains the logic for standardizing font sizes.

// src/utils/normalize-font.ts

import { Dimensions } from "react-native";
import { RFValue } from "react-native-responsive-fontsize";

export const normalizeFont = (value: number) => {
  const { height } = Dimensions.get("window");
  const valueNormalized = RFValue(value, height);

  return valueNormalized;
};
Enter fullscreen mode Exit fullscreen mode

Initial Structure

// src/app/index.tsx

import { View, Text } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

export default function Home() {
  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            backgroundColor: "#f0000099",
            alignItems: "center",
            justifyContent: "center",
            padding: 40,
          }}
        >
          <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
            Header
          </Text>
        </View>

        {/* Content Container */}
        <View
          style={{
            backgroundColor: "green",
            alignItems: "center",
            justifyContent: "center",
            paddingVertical: 160,
            width: "100%",
          }}
        >
          <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
            Content
          </Text>
        </View>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Building the Content Container

Let's leave the Header for last since its behavior depends on the user's action in the content container.

Next steps are:

  • Change the View of the Content Container to ScrollView, as we need horizontal scroll and add steps inside the Content Container.
  • Adjust the width of the steps inside the Content Container.
  • Enable paging.
  • Navigation between steps.

Changing View of the Content Container to ScrollView

// src/app/index.tsx

import { View, Text, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

export default function Home() {
  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            backgroundColor: "#f0000099",
            alignItems: "center",
            justifyContent: "center",
            padding: 40,
          }}
        >
          <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
            Header
          </Text>
        </View>

        {/* Content Container */}
        <ScrollView
          style={{
            flexGrow: 0,
            alignContent: "center",
          }}
          contentContainerStyle={{
            flexGrow: 1,
            position: "relative",
          }}
          horizontal
        >
          <View
            style={{
              width: "100%",
              backgroundColor: "gray",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Welcome (Step 1)
            </Text>
          </View>
          <View
            style={{
              width: "100%",
              backgroundColor: "green",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Account Information (Step 2)
            </Text>
          </View>

          <View
            style={{
              width: "100%",
              backgroundColor: "orange",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Personal Information (Step 3)
            </Text>
          </View>

          <View
            style={{
              width: "100%",
              backgroundColor: "red",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Complete (Step 4)
            </Text>
          </View>
        </ScrollView>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

With the above structure, it's not possible to navigate horizontally because all steps have width = 100%, taking up all available space, hindering scrolling. To enable scrolling, we need to set the width of the steps equal to the available space of the content container, but without using 100% width, for example:

  • If the content container has a width = 370px, then all steps inside it must also have this width.

Adjusting the width of steps inside the content container

To have dynamic width for the steps of the content container, we will assign to each step's width the size of the device screen, minus any horizontal gap, padding, or margin that exists.

To get the device's width, we will use Dimensions, a method that comes from react-native.

We will also assign the variable screenPadding the screen's padding, which is 20.
In the variable stepFormWidth, we assign the result of the calculation of the screen's width minus the screen's padding. Since the screen's padding is 20 for the left side and 20 for the right side, we can multiply the variable screenPadding by 2.

import { Dimensions } from "react-native";

const { width } = Dimensions.get("window");

const screenPadding = 20;
const stepFormWidth = width - screenPadding * 2;
Enter fullscreen mode Exit fullscreen mode

Thus, the file looks like this:

// src/app/index.tsx

import { View, Text, Dimensions, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

const { width } = Dimensions.get("window");

export default function Home() {
  const screenPadding = 20;
  const stepFormWidth = width - screenPadding * 2;
  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            backgroundColor: "#f0000099",
            alignItems: "center",
            justifyContent: "center",
            padding: 40,
          }}
        >
          <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
            Header
          </Text>
        </View>

        {/* Content Container */}
        <ScrollView
          style={{
            flexGrow: 0,
            alignContent: "center",
          }}
          contentContainerStyle={{
            flexGrow: 1,
            position: "relative",
          }}
          horizontal
        >
          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "gray",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Welcome (Step 1)
            </Text>
          </View>
          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "green",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Account Information (Step 2)
            </Text>
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "orange",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Personal Information (Step 3)
            </Text>
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "red",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Complete (Step 4)
            </Text>
          </View>
        </ScrollView>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now that the Steps already have a specific size, we can make horizontal scrolling.

Enabling Paging

The way the horizontal scroll behaves is not the most ideal to use in a wizard form component, so we will make the scroll behave in a snap style, where scrolling just a little will move it to the center of the next step.

The ScrollView has the pagingEnabled property that allows enabling this snap-style behavior.

// ...
  <ScrollView
  // ...
    pagingEnabled
  >
// ...
Enter fullscreen mode Exit fullscreen mode

Navigation between steps.

Adding buttons for navigation between steps.

Now, within each step, we'll add buttons that allow moving forward or backward between steps.

// src/app/index.tsx

import { View, Text, Dimensions, Button, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

const { width } = Dimensions.get("window");

export default function Home() {
  const screenPadding = 20;
  const stepFormWidth = width - screenPadding * 2;
  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            backgroundColor: "#f0000099",
            alignItems: "center",
            justifyContent: "center",
            padding: 40,
          }}
        >
          <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
            Header
          </Text>
        </View>

        {/* Content Container */}
        <ScrollView
          style={{
            flexGrow: 0,
            alignContent: "center",
          }}
          contentContainerStyle={{
            flexGrow: 1,
            position: "relative",
          }}
          horizontal
          pagingEnabled
        >
          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "gray",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Welcome (Step 1)
            </Text>
            <Button
              title="continue"
              color="#000"
              onPress={() => {
                console.log("go to next step");
              }}
            />
          </View>
          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "green",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Button
              title="previous step"
              color="#000"
              onPress={() => {
                console.log("go to previous step");
              }}
            />
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Account Information (Step 2)
            </Text>
            <Button
              title="next step"
              color="#000"
              onPress={() => {
                console.log("go to next step");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "orange",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Button
              title="previous step"
              color="#000"
              onPress={() => {
                console.log("go to previous step");
              }}
            />
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Personal Information (Step 3)
            </Text>

            <Button
              title="next step"
              color="#000"
              onPress={() => {
                console.log("go to next step");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "red",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Complete (Step 4)
            </Text>
          </View>
        </ScrollView>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Welcome (Step 1) only has the possibility to proceed to the next step.
  • Account Information (Step 2) has the possibility to proceed and return.
  • Personal Information (Step 3) has the possibility to proceed and return.
  • Complete (Step 4) will be the completion screen, so there's no way to proceed or return.

Logic for navigation between steps.

Creating the Ref

To have navigation between steps, we need a reference to the ScrollView and use the scrollTo method, which allows us to scroll to a specific point on the X or Y axis.

import { useRef } from "react";

const scrollRef = useRef<ScrollView>(null);

// ...

<ScrollView
  ref={scrollRef}
  // ...
>
// ...
Enter fullscreen mode Exit fullscreen mode

With the reference to the scrollview component, we can use scrollTo to navigate horizontally to the left or right.

Navigate to the next step

To navigate to the next step, we need to tell our scrollview component to navigate along the X axis to a specific value, and since our steps have a size equal to the device's screen, reducing only the horizontal padding, we can use this information to navigate.
We start by creating a method to be used in the buttons to proceed.
Let's call it handlePressNextStep which takes as a parameter the next step you want to go to (nextStep).

Inside it, we create an object that will be responsible for having the step names as keys and the X-axis point that this component is present as values. Since pagingEnabled is activated in the scrollView, as soon as it scrolls into the next step, the scrollView will center the scroll inside this step.

As the components have the width the same size as the device screen, we can say that the second step is present on the X-axis at the value of screen width - horizontal padding and step 3 is present at the value of step 2 * 2.

Steps:

1st - Welcome (Step 1) - starts at x: 0.

2nd - Account Information (Step 2) - starts at x: (screen width - horizontal padding).

3rd - Personal Information (Step 3) - starts at x: (screen width - horizontal padding) * 2.

4th - Complete (Step 4) - starts at x: (screen width - horizontal padding) * 3.

example: My device has a width of 390px, and I added 20px of horizontal padding, so the size of my steps will be 370px:

steps values: 390px - 20px = 370px

  • So Welcome (Step 1) is at x-axis: 0.
  • Account Information (Step 2) is at x-axis: 370px.
  • Personal Information (Step 3) is at x-axis: 370px * 2 = 740px.
  • Complete (Step 4) is at x-axis: 370px * 3 = 1110px.

With this information, we create the method below:

const handlePressNextStep = (nextStep: string) => {
  const formSteps = {
    account: stepFormWidth,
    personal: stepFormWidth * 2,
    complete: stepFormWidth * 3,
  };

  scrollRef?.current?.scrollTo({
    x: formSteps[nextStep as keyof typeof formSteps],
  });
};
Enter fullscreen mode Exit fullscreen mode

Since we already started at the Welcome step, we can leave it out.

Navigate to the previous step

Just like in navigating to the next step, we create a method to scroll to the previous step, let's call it handlePressBackButton and it takes as a parameter the previous step you want to go to (previousStep).

const handlePressBackButton = (previousStep: string) => {
  const formSteps = {
    welcome: 0,
    account: stepFormWidth,
  };

  scrollRef?.current?.scrollTo({
    x: formSteps[previousStep as keyof typeof formSteps],
  });
};
Enter fullscreen mode Exit fullscreen mode

Using navigation

// src/app/index.tsx
import { useRef } from "react";
import { View, Text, Dimensions, Button, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

const { width } = Dimensions.get("window");

export default function Home() {
  const scrollRef = useRef<ScrollView>(null);
  const screenPadding = 20;
  const stepFormWidth = width - screenPadding * 2;

  const handlePressNextStep = (nextStep: string) => {
    const formSteps = {
      account: stepFormWidth,
      personal: stepFormWidth * 2,
      complete: stepFormWidth * 3,
    };

    scrollRef?.current?.scrollTo({
      x: formSteps[nextStep as keyof typeof formSteps],
    });
  };

  const handlePressBackButton = (previousStep: string) => {
    const formSteps = {
      welcome: 0,
      account: stepFormWidth,
    };

    scrollRef?.current?.scrollTo({
      x: formSteps[previousStep as keyof typeof formSteps],
    });
  };

  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            backgroundColor: "#f0000099",
            alignItems: "center",
            justifyContent: "center",
            padding: 40,
          }}
        >
          <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
            Header
          </Text>
        </View>

        {/* Content Container */}
        <ScrollView
          style={{
            flexGrow: 0,
            alignContent: "center",
          }}
          contentContainerStyle={{
            flexGrow: 1,
            position: "relative",
          }}
          horizontal
          pagingEnabled
          ref={scrollRef}
        >
          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "gray",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Welcome (Step 1)
            </Text>
            <Button
              title="continue"
              color="#000"
              onPress={() => {
                handlePressNextStep("account");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "green",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Button
              title="previous step"
              color="#000"
              onPress={() => {
                handlePressBackButton("welcome");
              }}
            />
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Account Information (Step 2)
            </Text>
            <Button
              title="next step"
              color="#000"
              onPress={() => {
                handlePressNextStep("personal");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "orange",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Button
              title="previous step"
              color="#000"
              onPress={() => {
                handlePressBackButton("account");
              }}
            />
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Personal Information (Step 3)
            </Text>

            <Button
              title="next step"
              color="#000"
              onPress={() => {
                handlePressNextStep("complete");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "red",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Complete (Step 4)
            </Text>
          </View>
        </ScrollView>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Disabling scroll

Now that your scroll is working through button presses, it's necessary to disable scrolling so that the user can navigate solely through the buttons.

To do this, it's very simple. The ScrollView has the scrollEnabled property, which is responsible for enabling or disabling its scroll. So, we set its value to false scrollEnabled={false}, and thus scrolling will no longer be possible, only navigation through the buttons.

Building the Header

Now that we have the content container part with the steps and navigation between them via buttons, let's create the Header.

In the Header, we'll have some labels indicating which step the user is on and how many steps are left to complete the form.

// src/app/index.tsx
import { Dimensions, Text, View } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

export default function Home() {
  // ...
  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "#2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            gap: 20,
            padding: 20,
          }}
        >
          <Text
            style={{
              fontSize: normalizeFont(20),
              color: "#FAF9F6",
              textTransform: "capitalize",
            }}
          >
            Create account steps
          </Text>

          {/* Steps Indicator Container */}
          <View
            style={{
              position: "relative",
              flexDirection: "row",
              alignItems: "center",
              justifyContent: "space-between",
            }}
          >
            {/* StepIndicator Line */}
            <View
              style={{
                position: "absolute",
                height: 1,
                width: "100%",
                backgroundColor: "#FAF9F6",
              }}
            />

            {/* StepIndicator Label Wrapper - Welcome */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Welcome Step
              </Text>
            </View>

            {/* StepIndicator Label Wrapper - Account Information */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Account Information
              </Text>
            </View>

            {/* StepIndicator Label Wrapper - Personal Information */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Personal Information
              </Text>
            </View>

            {/* Divisor */}
            <View
              style={{
                position: "absolute",
                bottom: -20,
                height: 1,
                width: "100%",
                backgroundColor: "#9ca3af",
              }}
            />
          </View>
        </View>

        {/* Content Container */}
        {/* ... */}
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Logic to indicate if the step is completed

With the labels already present in the header, we can implement logic to change the style of the label if the step has been completed.

And how do we know if it has been completed?

In the content container, when navigating to the next step, we'll consider it completed, and a change in the style of the corresponding label will be reflected. If it returns to the previous step, it should become incomplete again.

To do this, let's create a state that will store the list of completed steps:

const [stepsCompleted, setStepsCompleted] = useState<string[]>([]);
Enter fullscreen mode Exit fullscreen mode

And then, in the handlePressNextStep and handlePressBackButton functions, we implement the logic to add/remove the completed step from the list of steps.

The handlePressNextStep function will receive one more parameter, which will be the currentStep, and to better organize the parameters, let's put it as the first parameter of the function:

const handlePressNextStep = (currentStep: string, nextStep: string) => {
  setStepsCompleted((prevState) =>
    prevState.includes(currentStep) ? prevState : [...prevState, currentStep],
  );

  const formSteps = {
    account: stepFormWidth,
    personal: stepFormWidth * 2,
    complete: stepFormWidth * 3,
  };

  scrollRef?.current?.scrollTo({
    x: formSteps[nextStep as keyof typeof formSteps],
  });
};
Enter fullscreen mode Exit fullscreen mode

It's necessary to check before changing the state whether the current step is already in the list to avoid adding duplicate data to the list.

Now let's also add currentStep as the first parameter in the handlePressBackButton function:

const handlePressBackButton = (currentStep: string, previousStep: string) => {
  setStepsCompleted((prevState) =>
    // Validação adicionada para que retorne somente as etapas que sejam diferentes da atual e da anterior, deixando na lista apenas as etapas que já foram avançadas.
    prevState.filter((step) => step !== currentStep && step !== previousStep),
  );

  const formSteps = {
    welcome: 0,
    account: stepFormWidth,
  };

  scrollRef?.current?.scrollTo({
    x: formSteps[previousStep as keyof typeof formSteps],
  });
};
Enter fullscreen mode Exit fullscreen mode

The action buttons in the content container will look like this:

<ScrollView>
  <View
  //...
  >
    <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
      Welcome (Step 1)
    </Text>
    <Button
      title="continue"
      color="#000"
      onPress={() => {
        handlePressNextStep("welcome", "account");
      }}
    />
  </View>

  <View
  //...
  >
    <Button
      title="previous step"
      color="#000"
      onPress={() => {
        handlePressBackButton("account", "welcome");
      }}
    />
    <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
      Account Information (Step 2)
    </Text>
    <Button
      title="next step"
      color="#000"
      onPress={() => {
        handlePressNextStep("account", "personal");
      }}
    />
  </View>

  <View
  //...
  >
    <Button
      title="previous step"
      color="#000"
      onPress={() => {
        handlePressBackButton("personal", "account");
      }}
    />
    <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
      Personal Information (Step 3)
    </Text>

    <Button
      title="next step"
      color="#000"
      onPress={() => {
        handlePressNextStep("personal", "complete");
      }}
    />
  </View>

  <View
  //...
  >
    <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
      Complete (Step 4)
    </Text>
  </View>
</ScrollView>
Enter fullscreen mode Exit fullscreen mode

Now when navigating to the next step, we add the current step to the list, and when returning a step, we remove it from the list, for example:

We are in the Account Information step, so the steps list will be ['welcome'].

I moved to the Personal Information step, and the list will be ['welcome', 'account'].

I returned to the Account Information step, and the list returned to ['welcome'].

Changing the label style when completing the step

Now that we have a state with the list of completed steps, let's use it to determine if step "X" is present in the list. If it is, the background-color of the StepIndicator Label Wrapper will be "#22c55e", otherwise, it will be "#9ca3af".

// src/app/index.tsx
import { useState } from "react";
//...
export default function Home() {
  const [stepsCompleted, setStepsCompleted] = useState<string[]>([]);
  // ...
  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            gap: 20,
            padding: 20,
          }}
        >
          <Text
            style={{
              fontSize: normalizeFont(20),
              color: "#FAF9F6",
              textTransform: "capitalize",
            }}
          >
            Create account steps
          </Text>

          {/* Steps Indicator Container */}
          <View
            style={{
              position: "relative",
              flexDirection: "row",
              alignItems: "center",
              justifyContent: "space-between",
            }}
          >
            {/* StepIndicator Line */}
            <View
              style={{
                position: "absolute",
                height: 1,
                width: "100%",
                backgroundColor: "#FAF9F6",
              }}
            />

            {/* StepIndicator Label Wrapper - Welcome */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: stepsCompleted.includes("welcome")
                  ? "#22c55e"
                  : "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Welcome Step
              </Text>
            </View>

            {/* StepIndicator Label Wrapper - Account Information */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: stepsCompleted.includes("account")
                  ? "#22c55e"
                  : "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Account Information
              </Text>
            </View>

            {/* StepIndicator Label Wrapper - Personal Information */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: stepsCompleted.includes("personal")
                  ? "#22c55e"
                  : "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Personal Information
              </Text>
            </View>

            {/* Divisor */}
            <View
              style={{
                position: "absolute",
                bottom: -20,
                height: 1,
                width: "100%",
                backgroundColor: "#9ca3af",
              }}
            />
          </View>
        </View>

        {/* Content Container */}
        {/* ... */}
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Finished Code

And thus, we have the finished code with all the logic.

// src/app/index.tsx
import { useRef, useState } from "react";
import { Button, Dimensions, ScrollView, Text, View } from "react-native";
import { normalizeFont } from "../utils/normalize-font";

const { width } = Dimensions.get("window");

export default function Home() {
  const [stepsCompleted, setStepsCompleted] = useState<string[]>([]);
  const scrollRef = useRef<ScrollView>(null);
  const screenPadding = 20;
  const stepFormWidth = width - screenPadding * 2;

  const handlePressNextStep = (currentStep: string, nextStep: string) => {
    setStepsCompleted((prevState) =>
      prevState.includes(currentStep) ? prevState : [...prevState, currentStep],
    );

    const formSteps = {
      account: stepFormWidth,
      personal: stepFormWidth * 2,
      complete: stepFormWidth * 3,
    };

    scrollRef?.current?.scrollTo({
      x: formSteps[nextStep as keyof typeof formSteps],
    });
  };

  const handlePressBackButton = (currentStep: string, previousStep: string) => {
    setStepsCompleted((prevState) =>
      prevState.filter((step) => step !== currentStep && step !== previousStep),
    );

    const formSteps = {
      welcome: 0,
      account: stepFormWidth,
    };

    scrollRef?.current?.scrollTo({
      x: formSteps[previousStep as keyof typeof formSteps],
    });
  };

  return (
    // Screen Container
    <View
      style={{
        flexGrow: 1,
        alignItems: "center",
        justifyContent: "center",
        padding: 20,
      }}
    >
      {/* Sign Up Container */}
      <View
        style={{
          width: "100%",
          borderRadius: 2,
          backgroundColor: "#2d0381",
        }}
      >
        {/* Header */}
        <View
          style={{
            gap: 20,
            padding: 20,
          }}
        >
          <Text
            style={{
              fontSize: normalizeFont(20),
              color: "#FAF9F6",
              textTransform: "capitalize",
            }}
          >
            Create account steps
          </Text>

          {/* Steps Indicator Container */}
          <View
            style={{
              position: "relative",
              flexDirection: "row",
              alignItems: "center",
              justifyContent: "space-between",
            }}
          >
            {/* StepIndicator Line */}
            <View
              style={{
                position: "absolute",
                height: 1,
                width: "100%",
                backgroundColor: "#FAF9F6",
              }}
            />

            {/* StepIndicator Label Wrapper - Welcome */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: stepsCompleted.includes("welcome")
                  ? "#22c55e"
                  : "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Welcome Step
              </Text>
            </View>

            {/* StepIndicator Label Wrapper - Account Information */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: stepsCompleted.includes("account")
                  ? "#22c55e"
                  : "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Account Information
              </Text>
            </View>

            {/* StepIndicator Label Wrapper - Personal Information */}
            <View
              style={{
                maxWidth: 100,
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 8,
                paddingVertical: 4,
                paddingHorizontal: 8,
                backgroundColor: stepsCompleted.includes("personal")
                  ? "#22c55e"
                  : "#9ca3af",
              }}
            >
              <Text
                style={{
                  fontSize: normalizeFont(12),
                  color: "#FAF9F6",
                  fontWeight: "bold",
                }}
              >
                Personal Information
              </Text>
            </View>

            {/* Divisor */}
            <View
              style={{
                position: "absolute",
                bottom: -20,
                height: 1,
                width: "100%",
                backgroundColor: "#9ca3af",
              }}
            />
          </View>
        </View>

        {/* Content Container */}
        <ScrollView
          style={{
            flexGrow: 0,
            alignContent: "center",
          }}
          contentContainerStyle={{
            flexGrow: 1,
            position: "relative",
          }}
          horizontal
          pagingEnabled
          scrollEnabled={false}
          ref={scrollRef}
        >
          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "gray",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Welcome (Step 1)
            </Text>
            <Button
              title="continue"
              color="#000"
              onPress={() => {
                handlePressNextStep("welcome", "account");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "green",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Button
              title="previous step"
              color="#000"
              onPress={() => {
                handlePressBackButton("account", "welcome");
              }}
            />
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Account Information (Step 2)
            </Text>
            <Button
              title="next step"
              color="#000"
              onPress={() => {
                handlePressNextStep("account", "personal");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "orange",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Button
              title="previous step"
              color="#000"
              onPress={() => {
                handlePressBackButton("personal", "account");
              }}
            />
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Personal Information (Step 3)
            </Text>

            <Button
              title="next step"
              color="#000"
              onPress={() => {
                handlePressNextStep("personal", "complete");
              }}
            />
          </View>

          <View
            style={{
              width: stepFormWidth,
              backgroundColor: "red",
              alignItems: "center",
              justifyContent: "center",
              paddingVertical: 160,
            }}
          >
            <Text style={{ color: "white", fontSize: normalizeFont(30) }}>
              Complete (Step 4)
            </Text>
          </View>
        </ScrollView>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

There are several improvements to implement in this little project, but since the focus of this article is on building a horizontal snap scroll with steps, we'll leave the improvements and refactorings for another article.

If you're interested, you can follow the project's improvements through the repository by clicking here.

Top comments (6)

Collapse
 
mrlinxed profile image
Mr. Linxed

Have you looked into the css property scroll-snap-type

This could help you eliminate some JS.

Collapse
 
chriscode profile image
Christopher Alves

@mrlinxed thank you for your tip!
But when you are working with react-native to have a scroll, you need to use some scrollable component, like scrollview or flatlist.
The css scroll-snap-type property works very well, but only on the Web.

Collapse
 
mayderson profile image
Mayderson Mello

Muito massa essa implementação, ficou bem interessante, consegui replicar bem fácil.

Collapse
 
chriscode profile image
Christopher Alves

Obrigado Mayderson! Qualquer dúvida só chamar!

Collapse
 
luuan profile image
Luan Tomarozzi

Amazing article Chris! Thanks for sharing.

Collapse
 
chriscode profile image
Christopher Alves

No problem @luuan , I'm happy to help