Beautiful placeholders for images in React Native

18 minutes read

If you have ever developed a React Native application which renders images from the web, you have probably seen the effect of the image view being transparent while the content is downloading from the network and then appearing out of nowhere. This looks ugly and does not provide a good user experience. You can see the undesired content flash when displaying this list of user avatars:

We can improve the default behavior by using the defaultSource property on the Image element. It allows us to render a static image (loaded from the file system) to display while downloading the dynamic image off the network. Let's add a static placeholder avatar and see the results.

const avatarPlaceholderImg = require("./assets/avatar-placeholder.png")

const Avatar = ({ userId, size }) => {
  return (
    <Image
      style={{ width: size, height: size, borderRadius: size / 2 }}
      defaultSource={avatarPlaceholderImg}
      source={{
        uri: `https://example.com/avatar?size=${size}&user_id=${userId}`,
      }}
      width={size}
      height={size}
    />
  )
}

This solution is simple and works well in this case. This is not the optimal solution though - displaying one and the same placeholder image can sometimes ruin a great design.

There are situations where it's better to display dynamic placeholders which contain colors from or closer approximations of the original image. Suitable image types for this strategy are album art, book covers and photographs. This more advanced strategy allows for a smoother transition between the placeholder and the actual image. Platforms like Twitter, Unsplash, Medium and Mastodon use these effects.

What we are building

Let's create two advanced image placeholder components consisting of:

  • linear gradient with two "dominant colors" from the original image
  • "blurhash" - simplified blurred version of the original image encoded as a short string

We will also add a fade-in effect for smoother transition between placeholder and image using React Native's Animated library.

To show the advantages of advanced image fallbacks we will build a single screen from a music app which displays artists and their albums.

I've compiled some music artists' data which will simulate a response received from a REST API. I defined the dominant colors and generated the blurhashes for each of the album covers - in a real-world scenario your back-end service should automatically do these calculations at the time of image upload.

Demo application: Spotify-like screen displaying artists and albums
Demo application: Spotify-like screen displaying artists and albums

Prerequisites

Let's create a new React Native application with TypeScript support which will house our components. Open a terminal and execute the following command:

npx react-native init BeautifulImagePlaceholdersApp --template react-native-template-typescript

This creates a project with these main dependencies at the time of writing:

  • react-native: 0.63.3
  • react: 16.13.1
  • typescript: 3.8.3

Linear and radial gradients aren't supported in React Native core. To render views with gradient backgrounds we must install an external package called react-native-linear-gradient. Navigate to the newly created project's directory BeautifulImagePlaceholdersApp and run:

npm i react-native-linear-gradient

For the blurhash decoding we can take advantage of an awesome port for React Native called react-native-blurhash. It provides a component which renders blurhash strings as images.

npm i react-native-blurhash

The last step is to install the native iOS dependencies (Cocoapods) which are part of these external packages.

npx pod-install

Now we are ready to build and run the application on a device or simulator/emulator.

npx react-native run-ios # iOS
npx react-native run-android # Android

Gradient image placeholder

Create a new component called GradientPlaceholderImage.tsx in the components folder (which does not exist yet):

mkdir -p components && touch components/GradientPlaceholderImage.tsx
GradientPlaceholderImage.tsx
import React from "react"
import {
  Animated,
  Easing,
  ImageProps,
  StyleSheet,
  View,
  ViewStyle,
} from "react-native"
import LinearGradient from "react-native-linear-gradient"

interface Props extends ImageProps {
  colors: string[]
  width: number
  height: number
  angle?: number
  containerStyle?: ViewStyle
}

const GradientPlaceHolderImage: React.FC<Props> = props => {
  const {
    colors,
    angle = 15,
    width,
    height,
    containerStyle,
    ...imageProps
  } = props

  const [isFadeInFinished, toggleFadeInFinished] = React.useState(false)
  const imageDimensions = { width, height }

  const animatedOpacityValue = React.useRef(new Animated.Value(0)).current

  return (
    <View style={containerStyle}>
      {!isFadeInFinished && (
        <LinearGradient
          colors={colors}
          style={[styles.gradient, imageDimensions, imageProps.style]}
          useAngle
          angle={angle}
        />
      )}
      <Animated.Image
        {...imageProps}
        {...imageDimensions}
        style={[
          imageDimensions,
          imageProps.style,
          { opacity: animatedOpacityValue },
        ]}
        onLoad={e => {
          if (typeof imageProps.onLoad === "function") {
            imageProps.onLoad(e)
          }

          if (isFadeInFinished) {
            return
          }

          Animated.timing(animatedOpacityValue, {
            toValue: 1,
            delay: 0,
            isInteraction: false,
            useNativeDriver: true,
            easing: Easing.in(Easing.ease),
          }).start(() => toggleFadeInFinished(true))
        }}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  gradient: {
    position: "absolute",
    left: 0,
    top: 0,
  },
})

export default GradientPlaceHolderImage

The component accepts all properties of the original React Native Image component as well as colors and an angle in degrees for the gradient fallback. Users of the component must provide the exact size of the image element to allow the gradient fallback to match the dimensions of the image by passing the width and height props.

The gradient view is absolutely positioned under the image element. Since the image has a transparent background while it's loading, the gradient below renders on the screen instead.

We animate the image to fade into the gradient so that the transition between the placeholder and the image itself looks more natural. We use Animated.Image which allows us to pass Animated.Value objects as props. To achieve the desired fade-in effect we animate the opacity style prop of the image from 0 to 1 (transparent to opaque).

Because GradientPlaceHolderImage is a functional component, there is a subtle catch - we save the Animated.Value instance as a reference by using React.useRef(). If we don't do so, we will get a new instance of Animated.Value on each re-render and our animation won't behave as expected. In a React class component, we could achieve the same by storing the animated value in field:

  constructor(props) {
    super(props)
    this.animatedVal = new Animated.Value(0)
  }

After the image has finished loading we trigger the fade in animation by calling Animated.timing() with our animated opacity value. This will animate a given value over time with an easing function - a mathematical function which simulates realistic value changes in a given time interval. We provide Easing.in(Easing.ease) as the easing function and 1 as the desired final value after the animation finishes. When the transition completes, we remove the gradient fallback element from the view and the image remains the single view rendered by the component.

Putting GradientPlaceHolderImage into use

Let's see our fancy component in action by creating a Spotify-like list of artists and their albums. This scenario will require loading many artwork images and displaying them in a virtualized list - a perfect use-case to check the effectiveness of our component!

Start by creating a file which holds artists and albums data that we are going to render:

touch artists.ts

Copy the following contents into the newly created file:

artists.ts
export interface MusicAlbum {
  name: string
  year: number
  artwork: {
    url: string
    dominantColors: [string, string]
    blurhash: string
  }
}

export interface MusicArtist {
  name: string
  albums: MusicAlbum[]
}

// adds a fake query parameter to the image URL
// so that it's always fetched form network and not from cache
const artUrl = (path: string) =>
  `https://coverartarchive.org/release/${path}.jpg?${new Date()}`

const artists: MusicArtist[] = [
  {
    name: "Nine Inch Nails",
    albums: [
      {
        name: "Pretty Hate Machine",
        year: 1989,
        artwork: {
          url: artUrl("60a04a88-3956-49f5-9d0f-b2603be9f612/8270653258-500"),
          dominantColors: ["#d0498e", "#2d85b0"],
          blurhash: "UKC6~3T#W9rqK5S#X8ay9rS1r?r?w|#SWASg",
        },
      },
      {
        name: "The Downward Spiral",
        year: 1994,
        artwork: {
          url: artUrl("dceb6a01-3431-36af-b2e1-6462193bd67c/2196400361-500"),
          dominantColors: ["#dac47f", "#bea06d"],
          blurhash: "UVOMZftnxvog~o.8o#M}MyxXRQtQD*IUf5t6",
        },
      },
      {
        name: "The Fragile",
        year: 1999,
        artwork: {
          url: artUrl("ed2fe058-a25c-4bd0-b5ff-9a0f7661ad92/17884561006-500"),
          dominantColors: ["#4d4a55", "#ac2730"],
          blurhash: "URLBz#02ypR7Q7Vrxbs.cZova1oz%#RQofWB",
        },
      },
      {
        name: "With Teeth",
        year: 2005,
        artwork: {
          url: artUrl("44cbff72-9db6-4ad0-b4c8-b14986afc93a/10150118074-500"),
          dominantColors: ["#42455d", "#e6ebf0"],
          blurhash: "U-KUpBt7xuay~qWAt7fRIURjWBfRbcogRjay",
        },
      },
      {
        name: "Ghosts I-IV",
        year: 2008,
        artwork: {
          url: artUrl("a6db272a-22e6-485d-8d6b-e6d7f469a08c/15668674653-500"),
          dominantColors: ["#20231d", "#87887a"],
          blurhash: "UADl~2M|00t700t7~pWB~qRjNFof?uxuM{WB",
        },
      },
      {
        name: "The Slip",
        year: 2008,
        artwork: {
          url: artUrl("e292c47a-49d6-3b11-88f9-9938e77fd15b/4099941150-500"),
          dominantColors: ["#0d0d0d", "#6b2f2f"],
          blurhash: "UA9%n@of00j[eTIUxu-;00of~qWBx]-;IU00",
        },
      },
    ],
  },
  {
    name: "Russian Circles",
    albums: [
      {
        name: "Station",
        year: 2008,
        artwork: {
          url: artUrl("89c525d5-ba1a-448e-ade0-6617ce47eea7/5930134505-500"),
          dominantColors: ["#32302b", "#a7a6a4"],
          blurhash: "UKCP*Fayxuay~qofofWB-;t7ayWB%MWBWBay",
        },
      },
      {
        name: "Geneva",
        year: 2009,
        artwork: {
          url: artUrl("acac3cdb-ccd5-3736-87b3-1554de5a83be/3331811954-500"),
          dominantColors: ["#7f756d", "#181511"],
          blurhash: "UBA^5n%2Rj%L~WNGR*of4:M{t7NGM}of%2WB",
        },
      },
      {
        name: "Empros",
        year: 2009,
        artwork: {
          url: artUrl("65bc450d-2304-47d9-b114-e84b8bc56811/3331814020-500"),
          dominantColors: ["#dd3446", "#952530"],
          blurhash: "UjJ~1Y%25QWB}[t7NHoLEzW;xuofVtaytRkC",
        },
      },
      {
        name: "Memorial",
        year: 2013,
        artwork: {
          url: artUrl("14e2923f-2344-4d4c-9d24-02d18245412d/5929733555-500"),
          dominantColors: ["#1e334b", "#020b21"],
          blurhash: "U4A1P1ju00xvY8t8?bITD%flI9s:00%g-=bI",
        },
      },
      {
        name: "Guidance",
        year: 2016,
        artwork: {
          url: artUrl("ae8d42ac-58da-4dc1-974a-0c98c0a8640e/14303151485-500"),
          dominantColors: ["#cbd5d4", "#591d21"],
          blurhash: "UFH_Y|.8M{x]4TMxbHx]_NWVkCt700ayofRP",
        },
      },
    ],
  },
  {
    name: "Baroness",
    albums: [
      {
        name: "The Red Album",
        year: 2007,
        artwork: {
          url: artUrl("d5526d95-9288-4f45-ac86-9ff465792ecb/1369815832-500"),
          dominantColors: ["#e32244", "#dc6eaf"],
          blurhash: "U9Hm+RyCM{tkLz=zJ7%gQ=?ut7EM.8k?S#rE",
        },
      },
      {
        name: "Blue Record",
        year: 2009,
        artwork: {
          url: artUrl("c3fcab28-de11-4501-8ef8-131620b62bc7/9561609511-500"),
          dominantColors: ["#73aaad", "#0d66b1"],
          blurhash: "U7GI$F-g0102D#x|ETEf0O.8$y-O_2NGD+WF",
        },
      },
      {
        name: "Yellow & Green",
        year: 2012,
        artwork: {
          url: artUrl("2f14078e-cc9a-44c4-ac66-78dc4d182235/25038495680-500"),
          dominantColors: ["#56483b", "#edd445"],
          blurhash: "UXJ@jwNGWBsk~o-mR,M{?FNGxsRkozoHWCbI",
        },
      },
      {
        name: "Purple",
        year: 2015,
        artwork: {
          url: artUrl("cca4e60e-66cf-43aa-91be-6e07f980b328/15332374250-500"),
          dominantColors: ["#cb9db8", "#6755b8"],
          blurhash: "U8H^^~=yEFbx.AEm4]$*tC9ZIc%MELn,RW%2",
        },
      },
      {
        name: "Gold & Grey",
        year: 2019,
        artwork: {
          url: artUrl("352a670e-d7dc-4d7b-a8b1-2b28def1f63b/23323216427-500"),
          dominantColors: ["#4e524d", "#ea972e"],
          blurhash: "U9Mhxs}@Djzp1~%K.7VX-nEgIoR%KjRQa1WB",
        },
      },
    ],
  },
]

export default artists

The file contains a sample object with music artists and their albums. I've manually went through the artworks and chose the "dominant" colors which we will use for the image placeholder gradients.

Now open the App.tsx file and replace its contents with the following:

App.tsx
import React from "react"
import {
  FlatList,
  ListRenderItemInfo,
  SafeAreaView,
  ScrollView,
  StatusBar,
  StyleSheet,
  Text,
  View,
} from "react-native"
import artists, { MusicAlbum } from "./artists"

import GradientPlaceholderImage from "./components/GradientPlaceholderImage"

const ARTWORK_SIZE = 160
const BG_COLOR = "#212121"

function renderAlbumListItem(listItem: ListRenderItemInfo<MusicAlbum>) {
  const { item: album } = listItem

  return (
    <View style={{ maxWidth: ARTWORK_SIZE, marginHorizontal: 8 }}>
      <GradientPlaceholderImage
        key={album.name + album.year}
        source={{
          uri: album.artwork.url,
        }}
        width={ARTWORK_SIZE}
        height={ARTWORK_SIZE}
        colors={album.artwork.dominantColors}
        style={{ borderRadius: 10 }}
      />
      <Text style={styles.albumTitleText}>{album.name}</Text>
      <Text style={styles.albumYearText}>{album.year}</Text>
    </View>
  )
}

function getAlbumListItemLayout(_: any, index: number) {
  return {
    length: ARTWORK_SIZE,
    offset: index * ARTWORK_SIZE,
    index,
  }
}

const App = () => {
  return (
    <>
      <SafeAreaView style={{ backgroundColor: BG_COLOR, flex: 1 }}>
        <StatusBar barStyle="light-content" backgroundColor="#222" />
        <ScrollView
          contentInsetAdjustmentBehavior="automatic"
          contentContainerStyle={{ paddingVertical: 12 }}
        >
          {artists.map(artist => (
            <View key={artist.name}>
              <Text style={styles.artistText}>{artist.name}</Text>
              <FlatList
                horizontal={true}
                data={artist.albums}
                renderItem={renderAlbumListItem}
                keyExtractor={album => album.name}
                getItemLayout={getAlbumListItemLayout}
                windowSize={1}
                initialNumToRender={3}
                maxToRenderPerBatch={3}
              />
            </View>
          ))}
        </ScrollView>
      </SafeAreaView>
    </>
  )
}

const styles = StyleSheet.create({
  artistText: {
    fontSize: 20,
    fontWeight: "600",
    color: "#fefefe",
    marginTop: 16,
    marginBottom: 8,
    paddingLeft: 8,
  },
  albumTitleText: {
    fontSize: 14,
    fontWeight: "500",
    color: "#ececec",
    paddingTop: 8,
    paddingHorizontal: 4,
  },
  albumYearText: {
    fontSize: 11,
    fontWeight: "300",
    color: "#bababa",
    paddingHorizontal: 4,
    paddingTop: 2,
    paddingBottom: 4,
  },
})

export default App

For each artist we are rendering a horizontal FlatList which contains the artist's albums. Each list item renders the album artwork, title and release date.

The renderAlbumListItem function encapsulates the rendering logic for each album. For performance reasons it's essential to declare it outside the component which renders the FlatList (or memoize it using React.useCallback). If we forget doing so, the function reference will change on each re-render and thus trigger a re-render for the FlatList itself and this can be expensive! In our case this is more of a precaution because the App component is the top-level one and it's not supposed to get re-rendered a lot.

Another interesting function is getAlbumListItemLayout - it allows us to improve the list scrolling performance by calculating what dimensions each list item has upfront. In this way the FlatList does not have to dynamically measure the width and height of each list item it's going to render. We can only apply this optimization if all our list items have the same height (for vertical lists) or width (for horizontal lists). In our case the max width of each album list item component is equal to ARTWORK_SIZE.

App component renders a ScrollView which contains a horizontal FlatList for each artist. To take advantage of the fact that we provide a custom getItemLayout function and we know the size of each element upfront, we can set the following properties:

  • windowSize - how many "scrolling screens" should the list render
  • initialNumToRender - how many list items should be rendered before the user scrolls the list
  • maxToRenderPerBatch - how many list items should be rendered when the user reaches the end of the list to fill it with new content

On most mobile phone screens users can see 3 album components at once - so setting initialNumToRender and maxToRenderPerBatch to 3 is a sensible value. We set windowSize to 1 which means we don't want the list to pre-render off screen content - this will help us to test our image fallback because we will always have images loading at the moment when we scroll the lists. For a better UX it's better to pre-render a few screens ahead of time so that users don't see blank spaces or loading indicators when they scroll. However, our goal is to see the GradientPlaceholderImage component we built in action and lazy rendering makes it easier to observe its behavior.

Let's see what we built so far:

As you can see, a colorful gradient renders before an album artwork loads. There's also a fade-in effect which makes the transition between placeholder and actual image smooth and gracious.

Blurhash placeholder image implementation

Let's now create a component which renders a blurhash as a placeholder for an image.

touch components/GradientPlaceholderImage.tsx
BlurHashPlaceholderImage.tsx
import React from "react"
import {
  Animated,
  Easing,
  ImageProps,
  StyleSheet,
  View,
  ViewStyle,
} from "react-native"
import { Blurhash } from "react-native-blurhash"

interface Props extends ImageProps {
  width: number
  height: number
  blurhash: string
  containerStyle?: ViewStyle
}

const BlurHashPlaceholderImage: React.FC<Props> = ({
  blurhash,
  width,
  height,
  containerStyle,
  ...imageProps
}) => {
  const [isFadeInFinished, toggleFadeInFinished] = React.useState(false)
  const imageDimensions = { width, height }

  const animatedOpacityValue = React.useRef(new Animated.Value(0)).current

  return (
    <View style={containerStyle}>
      {!isFadeInFinished && (
        <Blurhash
          blurhash={blurhash}
          style={[styles.blurhash, imageDimensions, imageProps.style]}
        />
      )}
      <Animated.Image
        {...imageProps}
        {...imageDimensions}
        style={[
          imageDimensions,
          imageProps.style,
          { opacity: animatedOpacityValue },
        ]}
        onLoad={e => {
          if (typeof imageProps.onLoad === "function") {
            imageProps.onLoad(e)
          }

          if (isFadeInFinished) {
            return
          }

          Animated.timing(animatedOpacityValue, {
            toValue: 1,
            delay: 0,
            isInteraction: false,
            useNativeDriver: true,
            easing: Easing.in(Easing.ease),
          }).start(() => toggleFadeInFinished(true))
        }}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  blurhash: {
    position: "absolute",
    left: 0,
    top: 0,
  },
})

export default BlurHashPlaceholderImage

This time instead of colors and gradient angle our component accepts a blurhash prop. The original Blurhash component accepts additional props which we aren't going to cover and expose as part of our public API. The rest of the implementation is the same as GradientPlaceholderImage.

To test our new component go to App.tsx, import the component and replace the references to the old one with it.

App.tsx
import BlurHashPlaceholderImage from './components/BlurHashPlaceholderImage'

...

function renderAlbumListItem(listItem: ListRenderItemInfo<MusicAlbum>) {
  const {item: album} = listItem;

  return (
    <View style={{maxWidth: ARTWORK_SIZE, marginHorizontal: 8}}>
      <BlurHashPlaceholderImage
        key={album.name + album.year}
        source={{
          uri: album.artwork.url,
        }}
        width={ARTWORK_SIZE}
        height={ARTWORK_SIZE}
        blurhash={album.artwork.blurhash}
        style={{borderRadius: 10}}
      />
      <Text style={styles.albumTitleText}>{album.name}</Text>
      <Text style={styles.albumYearText}>{album.year}</Text>
    </View>
  );
}

The result should look like this:

The blurhash placeholder resembles the real image even more accurately than before.

Refactoring our solution to improve code quality

Now that we made sure our placeholder components work as expected, let's take care of improving our code.

Extract the fade-in animation effect into a custom React hook

In both components we use the same fade in animation by duplicating (copy/pasting) the instructions needed for it. In this way we break the DRY principle. Let's extract our duplicated business logic into a React hook.

Create a new hooks folder and a new TypeScript file called useFadeInAnimation.ts:

mkdir -p hooks && touch ./hooks/useFadeInAnimation.ts
useFadeInAnimation.ts
import React, { useCallback } from "react"
import { Animated, Easing } from "react-native"

export function useFadeInAnimation(fadedInCallback?: () => void) {
  const [isFadeInFinished, toggleFadeInFinished] = React.useState(false)
  const animatedOpacityValue = React.useRef(new Animated.Value(0)).current

  const startFadeIn = useCallback(() => {
    if (!isFadeInFinished) {
      Animated.timing(animatedOpacityValue, {
        toValue: 1,
        delay: 0,
        isInteraction: false,
        useNativeDriver: true,
        easing: Easing.in(Easing.ease),
      }).start(() => {
        toggleFadeInFinished(true)

        if (typeof fadedInCallback === "function") {
          fadedInCallback()
        }
      })
    }
  }, [isFadeInFinished, animatedOpacityValue, fadedInCallback])

  return {
    fadedIn: isFadeInFinished,
    opacity: animatedOpacityValue,
    startFadeIn,
  }
}

Our custom hooks encapsulate all the implementation details for the fade-in animation. The hook returns the same values which we already use in our placeholder components while hiding the tricky internals - e.g. users of the hook don't have to store the animated value in a useRef() anymore. Callers can also provide a callback function which gets invoked once the animation has finished.

Let's go to BlurHashPlaceholderImage.tsx and simplify the code by importing our custom hook:

BlurHashPlaceholderImage.tsx
// ...
import { useFadeInAnimation } from "../hooks/useFadeInAnimation"
// ...

const BlurHashPlaceholderImage: React.FC<Props> = ({
  blurhash,
  width,
  height,
  containerStyle,
  ...imageProps
}) => {
  const { fadedIn, opacity, startFadeIn } = useFadeInAnimation()
  const imageDimensions = { width, height }

  return (
    <View style={containerStyle}>
      {!fadedIn && (
        <Blurhash
          blurhash={blurhash}
          decodeAsync={true}
          style={[styles.blurhash, imageDimensions, imageProps.style]}
        />
      )}
      <Animated.Image
        {...imageProps}
        {...imageDimensions}
        style={[imageDimensions, imageProps.style, { opacity }]}
        onLoad={e => {
          if (typeof imageProps.onLoad === "function") {
            imageProps.onLoad(e)
          }
          startFadeIn()
        }}
      />
    </View>
  )
}

See how much more elegant and simple our component implementation looks! You can also use this hook in other components which require similar animation effect.

Move shared styles into their own file

We can go even further by moving the component styles into a separate file. The styles sheet rules are the same for both image placeholder components.

touch components/ImagePlaceholder.styles.ts
ImagePlaceholder.styles.ts
import { StyleSheet } from "react-native"

const ImagePlaceholderStyles = StyleSheet.create({
  placeholder: {
    position: "absolute",
    left: 0,
    top: 0,
  },
})

export default ImagePlaceholderStyles

I renamed the blurhash/gradient style property to the more generic placeholder so that it makes sense for both contexts.

Now we can edit our components and replace the initialization of the StyleSheet object with a reference to our shared object:

BlurHashPlaceholderImage.tsx
import ImagePlaceholderStyles from "./ImagePlaceholder.styles"

// ...
return (
  // ...
  <Blurhash
    //..
    style={[styles.placeholder, imageDimensions, imageProps.style]}
  />
  // ...
)
// ...

const styles = ImagePlaceholderStyles

Define common props interface

Some React props are shared between the two components that we created. We can define this relationship using TypeScript interfaces. Create a file called types.ts and enter the following interface definitions:

types.ts
import { ImageProps, ViewStyle } from "react-native"

export interface GradientPlaceholderImageProps extends ImagePlaceholderProps {
  colors: string[]
  angle?: number
}

export interface BlurHashPlaceholderImageProps extends ImagePlaceholderProps {
  blurhash: string
}

/**
 * Common props shared for both components.
 */
export interface ImagePlaceholderProps extends ImageProps {
  width: number
  height: number
  containerStyle?: ViewStyle
}

Now you can replace the props interfaces declared inside the components with these new definitions. The common interface might be useful in the future, if you want to create a similar component.

Conclusion

I hope these image placeholders will be useful for your React Native application! You can find the full source code on GitHub.