React native: Animated “Loading Skeleton” Component Made Easy

The Activity Indicator is a ubiquitous component in the React Native toolkit for indicating “work” being done in an app. It’s super easy to drop one on a screen and tie it to a loading state to show users when you are fetching content or performing other long running operations. Done and done! But, at no offense to the hard working Activity Indicator… it’s a bit boring, isn’t it? Contemporary UX wisdom suggests that an users may find loading to be more engaging (and may perceive loading happening faster) if the loading indicator is a facsimile of the type of content the app is trying to load, replacing expected content with “scaffolding content” or a loading skeleton. In this post, we’ll explore a quick way to build a bespoke loading skeleton system for use in a React Native app.

For ease of setup, we’ll build the pieces that make up our loading skeleton in an Expo Snack, but you can just as easily do so in a full-fledged React Native project. We’ll start with a new file in our components folder that will be home to all the inner working of our loading skeleton, including:

  • A pulsing animation to indicating loading
  • A “randomizer” to add variety to the number of lines of placeholder content and the length of the lines comprising the placeholder content
An example of a static loading skeleton

In practice, the most basic part of the loading skeleton we want to build is a single pulsing rectangle. We’ll start our journey there:

const LoadingRect = (props: {
  width: string | number;
  height: string | number;
  style?: StyleProp<ViewStyle>;
}) => {
  const pulseAnim = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    const sharedAnimationConfig = {
      duration: 1000,
      useNativeDriver: true,
    };
    Animated.loop(
      Animated.sequence([
        Animated.timing(pulseAnim, {
          ...sharedAnimationConfig,
          toValue: 1,
          easing: Easing.out(Easing.ease),
        }),
        Animated.timing(pulseAnim, {
          ...sharedAnimationConfig,
          toValue: 0,
          easing: Easing.in(Easing.ease),
        }),
      ])
    ).start();

    return () => {
      // cleanup
      pulseAnim.stopAnimation();
    };
  }, []);

  const opacityAnim = pulseAnim.interpolate({
    inputRange: [0, 1],
    outputRange: [0.05, 0.15],
  });

  return (
    <Animated.View
      style={[
        styles.LoadingRect,
        { width: props.width, height: props.height },
        { opacity: opacityAnim },
        props.style,
      ]}
    />
  );
};

Essentially this block of code is simply setting up our shape to animate with a given color. We establish an animation that eases from an opacity of 0.15 to an opacity of 0.5, the repeats indefinitely. At this point, our shape is unstructured; since we pass in height and width as required parameters as well as standard view “styles”, we have the flexibility to use this function component in many different configurations. We could even pass it a border radius to turn our rectangle into a circle or oval! Let’s add some more supplementary bits before putting everything together into a single loading skeleton:

// Supplementary wrapper so we don't have to rewrite structure a bunch
const Row = ({ style, ...otherProps }) => {
  return <View style={[{ flexDirection: 'row' }, style]} {...otherProps} />;
};

const LoadingText = () => {
  // return 0, 1, or 2. Add 1 to ensure we always have at least 1 line
  const lineCount = Math.floor(Math.random() * 3) + 1;
  const lines = new Array(lineCount).fill(0); 

  return lines.map((line) => {
    const range = (100 - 65) / 5;
    const width = Math.floor(Math.random() * range) * 5 + 70
    const lineLength = `${width}%`;

    return (
      <Row style={{ marginBottom: 8 }}>
        <LoadingRect width={lineLength} height={20} />
      </Row>
    );
  });
};

The LoadingText component here does the bulk of the work we need to inject some variety into our loading skeleton. Using a crude randomizer function, we ensure that each card in our loading skeleton will draw itself between 1 and 3 lines of “text”. I’ve opted for a fixed range in this instance, but note that you could make this even more flexible by adding the min (1) and max (3) number of lines a props, allowing this function to be deployed to large paragraphs or some such. We then take our random-ish number and create an empty array, which is then iterated over using a map function. Within the map function, we use another crude randomizer function to create a bar that is at least 70% of the width of the container wide up to 100% wide in 5% increments.

With those two pieces out of the way, we’re ready to start pulling together the bones of our loading skeleton. For the sake of demonstration, I’ve prepared two flavors: a short card with a title placeholder and a sub-heading placeholder, as well as a larger card that included an image placeholder, some heading content placeholders (which makes use of the LoadingText component we built above) and a fixed content placeholder, like we might use to represent a timestamp or an author field. Since both of these will be used outside the world of this general file, we’ll be exporting both components for further use:

export const SortCard = () => {
  return (
    <View style={[styles.card, sortCardStyles.sortCard]}>
      <LoadingRect width={80} height={14} />
      <LoadingRect width={'100%'} height={32} style={sortCardStyles.sort} />
    </View>
  );
};

export const LoadingArticle = () => {
  return (
    <View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
      <Row style={{}}>
        <LoadingRect width={100} height={100} style={{ marginRight: 16 }} />
        <View style={{ flex: 1, flexDirection: 'column' }}>
          {LoadingText()}
          <Row style={{ flex: 1, alignItems: 'flex-end' }}>
            <LoadingRect width={36} height={12} />
          </Row>
        </View>
      </Row>
    </View>
  );
};

Notice how we are composing each to make use of the parts built previously. We can compose these pieces in kind to build out a whole page of skeleton content, like so:

export default function App() {
  const [isConnected, recheckConnection] = useState(true);
  const [isLoading, setIsLoading] = useState(true);

  // Fill an array with empty data
  const [cardArray, setCardArray] = useState(new Array(3).fill(0));

  useEffect(() => {
    // handle loading data here, or wherever it makes sense to
  }, []);

  const renderLoadingContent = () => {
    return (
      <>
        <Card style={styles.card}>
          <SortCard />
        </Card>
        {cardArray.map(() => {
          return (
            <Card style={styles.card}>
              <LoadingArticle />
            </Card>
          );
        })}
      </>
    );
  };

  return (
    <View style={styles.container}>
      {isLoading && renderLoadingContent()}
      <Button
        mode="contained"
        onPress={() => {
          setCardArray(new Array(3).fill(0));
          setIsLoading(true);
        }}>
        Reload Cards
      </Button>
    </View>
  );
}

Just for fun, if we tie the array of cards to state, we can refresh the page and see the random-ish functions at work without reloading the whole app. The end result should look something like the following:

Loading Skeleton in action

Don't give up, skeleton!

And that all it takes! Except for the hard part of, you know, making content 😉

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s