Updated August 24, 2022

Setting Max Width When Supporting Web and Mobile in React Native

React Native's Web support continually gets better. With that, we've got to consider different screen sizes and orientations.

While building a React Native version of the game "Match the Pairs" (100% open source) I found that when playing the game in a horizontal landscape on the web everything looked off. The cards were wider than they were tall - I want to maintain the traditional playing card shape on the game board, regardless of screen size.

App running on the web without bounds

The Reality of Supporting Multiple Platforms

Before diving into the code I want to make sure you have proper expectations around targeting multiple platforms with a single codebase.

Often when I talk to people about the universal nature of React Native there is an assumption that you get to support iOS, Android, and the Web from one code base with 100% code sharing.

That is unrealistic. You can absolutely support iOS, Android, and the Web from a single codebase that is MOSTLY shared... but there will be differences!

But each platform is going to have unique requirements to make it fit the platform the user is running it on.

This sometimes requires customizations to your layouts and code. That's not a bad thing - you're still able to share a TON of code!

This simple app is a great example - this article shows layout change requirements and a previous article outlines the differences between mobile and the web when handling sharing.

Now, with that out of the way lets dive into the code.

The Starting Code

First, lets look at the code that drives the current UI.

import { SafeAreaView, StyleSheet, View, Text } from "react-native"
import { StatusBar } from "expo-status-bar"

import { StatsCard, GameCard } from "components/cards"
import { Button } from "components/buttons"

import { Spacing, Colors } from "constants/index"

import { useMatchGame } from "hooks/useMatchGame"
import { shareGame } from "src/utils/shareGame"

const ROWS = [
  [0, 1, 2, 3],
  [4, 5, 6, 7],
  [8, 9, 10, 11],
  [12, 13, 14, 15],
]

const MatchThePairs = () => {
  const {
    emojis,
    reset,
    chooseCard,
    activeCardIndex,
    matchedCards,
    comparisonCards,
    totalMoves,
    matchCount,
    totalPairs,
  } = useMatchGame()

  const handlePress = (index: number) => {
    chooseCard(index)
  }

  const handleShare = () => {
    if (matchCount === totalPairs) {
      shareGame({ emojis, moveCount: totalMoves })
    } else {
      alert("You haven't matched all the pairs yet!")
    }
  }

  return (
    <SafeAreaView style={styles.container}>
      <StatusBar style="dark" />
      <View style={[styles.row, styles.header]}>
        <Text style={styles.headerText}>Match the pairs 🤔</Text>
      </View>

      <View style={[styles.row, styles.stats]}>
        <StatsCard
          title="Pairs matched"
          numerator={matchCount}
          denominator={totalPairs}
        />
        <StatsCard title="Total moves" numerator={totalMoves} />
      </View>

      {ROWS.map((indices, rowIndex) => (
        <View style={[styles.row, styles.gameRow]} key={rowIndex}>
          {indices.map(emojiIndex => {
            const inMatchedCard = matchedCards.includes(emojiIndex)
            const cardIsVisible =
              inMatchedCard || comparisonCards.includes(emojiIndex)

            return (
              <GameCard
                key={emojiIndex}
                index={emojiIndex}
                emojis={emojis}
                onPress={() => handlePress(emojiIndex)}
                selected={activeCardIndex === emojiIndex}
                visible={cardIsVisible}
                disabled={inMatchedCard}
              />
            )
          })}
        </View>
      ))}

      <View style={[styles.row, styles.actions]}>
        <Button type="primary" onPress={() => reset()}>
          Reset game
        </Button>
        <Button onPress={handleShare}>Share game</Button>
      </View>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingVertical: Spacing.lg,
  },
  headerText: {
    color: Colors.greyDarkest,
    fontSize: 20,
    fontWeight: "600",
    marginTop: Spacing.md,
    marginBottom: Spacing.xl,
  },
  row: {
    flexDirection: "row",
    marginHorizontal: Spacing.sm,
  },
  gameRow: {
    flex: 1,
  },
  header: {
    marginHorizontal: Spacing.md,
  },
  stats: {
    marginBottom: Spacing.lg,
  },
  actions: {
    justifyContent: "center",
    marginTop: Spacing.xl,
  },
})

export default MatchThePairs

Layout for Web and Mobile in React Native

To fix the issue we'll do 3 things

  1. Wrap the game board in a View with the content styles
  2. Set the content to be full width but have a max width value
  3. Center the content view within the container.
/* ... */

const MatchThePairs = () => {
  const {
    emojis,
    reset,
    chooseCard,
    activeCardIndex,
    matchedCards,
    comparisonCards,
    totalMoves,
    matchCount,
    totalPairs,
  } = useMatchGame()

  const handlePress = (index: number) => {
    /* ... */
  }

  const handleShare = () => {
    /* ... */
  }

  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.content}>
        <StatusBar style="dark" />
        <View style={[styles.row, styles.header]}>
          <Text style={styles.headerText}>Match the pairs 🤔</Text>
        </View>

        <View style={[styles.row, styles.stats]}>
          <StatsCard
            title="Pairs matched"
            numerator={matchCount}
            denominator={totalPairs}
          />
          <StatsCard title="Total moves" numerator={totalMoves} />
        </View>

        {ROWS.map((indices, rowIndex) => (
          <View style={[styles.row, styles.gameRow]} key={rowIndex}>
            {indices.map(emojiIndex => {
              const inMatchedCard = matchedCards.includes(emojiIndex)
              const cardIsVisible =
                inMatchedCard || comparisonCards.includes(emojiIndex)

              return (
                <GameCard
                  key={emojiIndex}
                  index={emojiIndex}
                  emojis={emojis}
                  onPress={() => handlePress(emojiIndex)}
                  selected={activeCardIndex === emojiIndex}
                  visible={cardIsVisible}
                  disabled={inMatchedCard}
                />
              )
            })}
          </View>
        ))}

        <View style={[styles.row, styles.actions]}>
          <Button type="primary" onPress={() => reset()}>
            Reset game
          </Button>
          <Button onPress={handleShare}>Share game</Button>
        </View>
      </View>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingVertical: Spacing.lg,
    alignItems: "center",
  },
  content: {
    flex: 1,
    width: "100%",
    maxWidth: 550,
  },
  // ...
})

export default MatchThePairs

Let's dive into why we do each of these things.

First, we wrap our board in a second View so that the SafeAreaView can still take up the full screen (allowing for any background changes or other full screen changes).

We then use styles.content to fill the full vertical height, set the width to be 100% on portrait screens, and set a max width on it so that the playing cards remain the correct shape.

Finally we add alignItems: 'center' to the styles.container to center our game board. You can accomplish the same thing by adding marginHorizontal: 'auto' to styles.content but I prefer to use Flexbox whenever possible.

All of that results in the following game on the web:

App running on the web with bounds

You can find the full open source React Native game on Github.

React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our courses and our private Slack community.

Learn More