Mazzarolo MatteoMazzarolo Matteo

React Native responsive scaling on the web

By Mazzarolo Matteo


If you're a React-Native developer you might already be familiar with the concept of "scaling" views in your apps to look good on devices of different pixel densities and sizes.

There are many ways to do it: from using existing libraries like react-native-reponsive-screen to rolling your custom solution.

Please notice that if you're trying to follow the iOS and Android design guidelines in your apps, you shouldn't scale all your views, otherwise users won't get any benefit from using devices with bigger screens.

Personally, in most of my apps that require views to be scaled I create a scale utility that looks somewhat this:

import { PixelRatio, Dimensions, Platform } from "react-native";
 
// Determine if the device pixel density and size are tablet-like.
// For better accuracy, you can also use the react-native-device-info library.
function isTabletLike() {
  const pixelDensity = PixelRatio.get();
  const windowDimensions = Dimensions.get("window");
  const adjustedWidth = windowDimensions.width * pixelDensity;
  const adjustedHeight = windowDimensions.height * pixelDensity;
  if (pixelDensity < 2 && (adjustedWidth >= 1000 || adjustedHeight >= 1000)) {
    return true;
  } else return pixelDensity === 2 && (adjustedWidth >= 1920 || adjustedHeight >= 1920);
}
 
// Given an input number, scale it to suit devices with different sizes and
// pixel densities.
function scale(size) {
  // Fixed base width that has worked well for most of my use cases
  const baseWidth = isTabletLike() ? 520 : 350;
  const windowDimensions = Dimensions.get("window");
  const shorterWindowDimension =
    windowDimensions.width > windowDimensions.height
      ? windowDimensions.height
      : windowDimensions.width;
  return (shorterWindowDimension / baseWidth) * size;
}
import { PixelRatio, Dimensions, Platform } from "react-native";
 
// Determine if the device pixel density and size are tablet-like.
// For better accuracy, you can also use the react-native-device-info library.
function isTabletLike() {
  const pixelDensity = PixelRatio.get();
  const windowDimensions = Dimensions.get("window");
  const adjustedWidth = windowDimensions.width * pixelDensity;
  const adjustedHeight = windowDimensions.height * pixelDensity;
  if (pixelDensity < 2 && (adjustedWidth >= 1000 || adjustedHeight >= 1000)) {
    return true;
  } else return pixelDensity === 2 && (adjustedWidth >= 1920 || adjustedHeight >= 1920);
}
 
// Given an input number, scale it to suit devices with different sizes and
// pixel densities.
function scale(size) {
  // Fixed base width that has worked well for most of my use cases
  const baseWidth = isTabletLike() ? 520 : 350;
  const windowDimensions = Dimensions.get("window");
  const shorterWindowDimension =
    windowDimensions.width > windowDimensions.height
      ? windowDimensions.height
      : windowDimensions.width;
  return (shorterWindowDimension / baseWidth) * size;
}

Then, where needed, I can scale dimensions by invoking the scale function anywhere in the app code:

import { BeautifulIcon } from "icons";
import { StyleSheet, Text, View } from "react-native";
import { scale } from "utils/scale";
 
function ExampleView() {
  return (
    <View style={styles.root}>
      <Text style={styles.text}>Hello world!</Text>
      <BeautifulIcon size={scale(10)} />
    </View>
  );
}
 
const styles = StyleSheet.create({
  root: {
    width: "100%",
    height: scale(25),
  },
  text: {
    fontSize: scale(18),
  },
});
import { BeautifulIcon } from "icons";
import { StyleSheet, Text, View } from "react-native";
import { scale } from "utils/scale";
 
function ExampleView() {
  return (
    <View style={styles.root}>
      <Text style={styles.text}>Hello world!</Text>
      <BeautifulIcon size={scale(10)} />
    </View>
  );
}
 
const styles = StyleSheet.create({
  root: {
    width: "100%",
    height: scale(25),
  },
  text: {
    fontSize: scale(18),
  },
});

This approach works fine for the most common use cases, but it has one major flaw: although dimensions are available immediately, they may change so any rendering logic or styles that depend on these constants won't be updated automatically when the device dimensions are updated.

There are only a handful of cases when Android and iOS device dimensions change (e.g. device rotation, foldable devices), but this issue is particularly evident if you're using React Native on the web because each time you resize your browser windows you're technically updating the dimensions.

As an example, here's how Ordinary Puzzles' interface (a React Native puzzle game — which requires being responsive to different sizes given that is playable in the browser) doesn't scale when the window is resized:

The solution?
We must invoke scale on every render to avoid using a cached result (for example, when we invoke it within StyleSheet.create).

Luckily, React Native offers a handy useWindowDimensions hook that we can use to create a useScale hook to solve our issue:

import { useWindowDimensions, Platform } from "react-native";
 
// Determine if the device pixel density and size are tablet-like.
// For better accuracy, you can also use the react-native-device-info library.
function isTabletLike(windowDimensions) {
  const pixelDensity = windowDimensions.scale;
  const adjustedWidth = windowDimensions.width * pixelDensity;
  const adjustedHeight = windowDimensions.height * pixelDensity;
  if (pixelDensity < 2 && (adjustedWidth >= 1000 || adjustedHeight >= 1000)) {
    return true;
  } else return pixelDensity === 2 && (adjustedWidth >= 1920 || adjustedHeight >= 1920);
}
 
// Returns a scaling function that, given an input number, scale it to suit
// devices with different sizes and pixel densities.
function useScale() {
  const windowDimensions = useWindowDimensions();
  let baseWidth;
  if (Platform.OS === "android" || Platform.OS === "ios") {
    // Fixed base width that has worked well for most of my use cases
    baseWidth = isTabletLike() ? 520 : 350;
  } else {
    // For web, macOS, or Windows builds.
    // Potentially, you can use breakpoints here for a truly responsive design.
    // Or even debounce the result to avoid stressing the CPU while the user is
    // resizing the window.
    baseWidth = 800;
  }
  const shorterWindowDimension =
    windowDimensions.width > windowDimensions.height
      ? windowDimensions.height
      : windowDimensions.width;
  return (size) => (shorterWindowDimension / baseWidth) * size;
}
import { useWindowDimensions, Platform } from "react-native";
 
// Determine if the device pixel density and size are tablet-like.
// For better accuracy, you can also use the react-native-device-info library.
function isTabletLike(windowDimensions) {
  const pixelDensity = windowDimensions.scale;
  const adjustedWidth = windowDimensions.width * pixelDensity;
  const adjustedHeight = windowDimensions.height * pixelDensity;
  if (pixelDensity < 2 && (adjustedWidth >= 1000 || adjustedHeight >= 1000)) {
    return true;
  } else return pixelDensity === 2 && (adjustedWidth >= 1920 || adjustedHeight >= 1920);
}
 
// Returns a scaling function that, given an input number, scale it to suit
// devices with different sizes and pixel densities.
function useScale() {
  const windowDimensions = useWindowDimensions();
  let baseWidth;
  if (Platform.OS === "android" || Platform.OS === "ios") {
    // Fixed base width that has worked well for most of my use cases
    baseWidth = isTabletLike() ? 520 : 350;
  } else {
    // For web, macOS, or Windows builds.
    // Potentially, you can use breakpoints here for a truly responsive design.
    // Or even debounce the result to avoid stressing the CPU while the user is
    // resizing the window.
    baseWidth = 800;
  }
  const shorterWindowDimension =
    windowDimensions.width > windowDimensions.height
      ? windowDimensions.height
      : windowDimensions.width;
  return (size) => (shorterWindowDimension / baseWidth) * size;
}

And then update our code to make sure we generate a new scale function on each render:

import { BeautifulIcon } from "icons";
import { StyleSheet, Text, View } from "react-native";
import { useScale } from "utils/scale";
 
function ExampleView() {
  // Make sure our scaling function is always using the most up-to-date
  // dimensions by creating it through a hook.
  const scale = useScale();
  // To use the scaling function in non-inline styles, we can re-create them
  // on each render instead of caching them with "StyleSheet.create" (which
  // doesn't seem to offer too many benefits anyway, from my understanding).
  // Alternatively, you can continue using "StyleSheet.create" for generating
  // styles that don't need to be scaled, and inline just the scaled sizes.
  const styles = createStyles(scale);
  return (
    <View style={styles.root}>
      <Text style={styles.text}>Hello world!</Text>
      <BeautifulIcon size={scale(10)} />
    </View>
  );
}
 
const createStyles = (scale) => ({
  root: {
    width: "100%",
    height: scale(25),
  },
  text: {
    fontSize: scale(18),
  },
});
import { BeautifulIcon } from "icons";
import { StyleSheet, Text, View } from "react-native";
import { useScale } from "utils/scale";
 
function ExampleView() {
  // Make sure our scaling function is always using the most up-to-date
  // dimensions by creating it through a hook.
  const scale = useScale();
  // To use the scaling function in non-inline styles, we can re-create them
  // on each render instead of caching them with "StyleSheet.create" (which
  // doesn't seem to offer too many benefits anyway, from my understanding).
  // Alternatively, you can continue using "StyleSheet.create" for generating
  // styles that don't need to be scaled, and inline just the scaled sizes.
  const styles = createStyles(scale);
  return (
    <View style={styles.root}>
      <Text style={styles.text}>Hello world!</Text>
      <BeautifulIcon size={scale(10)} />
    </View>
  );
}
 
const createStyles = (scale) => ({
  root: {
    width: "100%",
    height: scale(25),
  },
  text: {
    fontSize: scale(18),
  },
});

Here's the result (ignore the janky jumping around of the interface, it was recorded in development mode):

The scaling API and its usage are a bit more complicated to use now, but I think the result is worth the effort.
Also, regardless of scaling or not part of the UI, I think creating styles on each render (or use solutions like styled-components) is a good idea to simplify your app theming (e.g., to immediately update the app color based on the device dark/light theme preference, etc.).

Please notice that the React Native Dimensions API exposes the fontscale property as well. Some operating systems allow users to scale their font sizes larger or smaller for reading comfort, so you should consider this size when creating/scaling custom text components.