React Photo Gallery Tutorial

Posted by Kseniya on June 5, 2016

Part 1: Display A List of Photos

Too long? Skip the text and jump straight to the code.

React is a declarative Javascript library that allows you to create reusable UI components that can handle events and data changes. One way to specify these components is through JSX - a JavaScript syntax extension. Here's what a component representing a photo might look like:

<Photo photoURL="images/IMG_1.jpg" />

JSX syntax must be transformed into Javascript before it can be rendered in the browser. This is done by using a transformer like Babel (more on this later). For now, let's create a PhotoGallery.jsx file and define a basic Photo component!

1.1 Photo Component

The Photo component will represent a single photo view in our gallery. A component usually starts with an upper case letter and is defined using the React.createClass function. For now, the only function the component will have is a render function that returns the html for displaying an image.

var Photo = React.createClass({
  render: function() {
    return (
      <img src="images/IMG_1.jpg" />
    );
  }
});

In order to reuse this component for multiple photos, we need to pass in the image URL instead of hard-coding it inside the img tag. React components receive information via the props object, so we can add a photoURL property to it and pass in a different URL for each photo.

The render method now looks like this:

render: function() {
  return (
    <img src={this.props.photoURL} />
  );
}

Note that since photoURL is a Javascript expression we must place it in curly braces to evaluate it when assigning it to the src attribute.

Let's update Photo to use a div with a styled background image instead of a plain img tag. This will help us add some fancy functionality later.

var Photo = React.createClass({
  render: function() {
    var divStyle = {
      backgroundImage: 'url(' + this.props.photoURL + ')'
    };

    return (
      <div className="photoPreview" style={divStyle} />
    );
  }
});
.photoPreview {
  display: inline-block;
  overflow: hidden;

  width: 200px;
  height: 200px;
  margin: 0 2px;

  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;

  cursor: pointer;
}

We want the background image of our div to point to the photo URL. However we are getting it dynamically from the props object so we can't reference it inside a static CSS file. The convention for dynamic CSS properties is to store them in a separate object and assign it to the style attribute. The static CSS properties can go into a separate CSS file as usual, with the corresponding class name assigned to the className property. When writing HTML we normally use the class attribute for this, but since JSX is Javascript, we don't want to conflict with the reserved class word.

1.2 Photo Gallery Component

Now that we have a Photo component, let's build a Photo Gallery component that will display multiple photos in a row.

Just like before, the PhotoGallery component has a render method that returns the HTML necessary to display its contents. For now let's assume that a list of photo URLs is passed in through the photoURLs property. We loop over these URLs in the render method to create an array of Photo components. These will then be included as children of the div in the return statement.

var PhotoGallery = React.createClass({
  render: function() {
    var photos = [];
    for(var i = 0; i < this.props.photoURLs.length; i++)
    {
      photos.push(<Photo photoURL={this.props.photoURLs[i]} /> );
    }

    return (
      <div className="photoGallery">
        { photos }
      </div>
    );
  }
});

1.3 HTML file

Now that we have a gallery, let's create an HTML file that will render it in the browser. The head contains a link to our CSS file:

  <head>
    <title>React Photo Gallery</title>
    <link rel="stylesheet" type="text/css" href="PhotoGalleryDemo.css" />
  </head>

In the body, we need a named div container for the PhotoGallery, the React and React-Dom libraries, the Babel JSX transformer, and the file with the JSX code we've written so far. Then we'll render the PhotoGallery into the container div using a short script.

  <body>
    <div id="photoGalleryContainer"/>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.1/react.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.1/react-dom.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js"></script>
    <script type="text/babel" src="PhotoGalleryDemo.jsx"></script>

    <script type="text/babel">
      var photos = ["images/IMG_1.jpg", "images/IMG_2.jpg", "images/IMG_3.jpg" ];

      ReactDOM.render(
        <PhotoGallery data={photos} />, document.getElementById('photoGalleryContainer')
      );
    </script>
  </body>

Since we need to transform the JSX, the script type is text/babel. Inside the script tag we add a call to ReactDOM.render. This render method takes in a PhotoGallery component and the div into which it should be rendered. Since we defined the PhotoGallery component to take in an array of photo URLs, we need to pass that in as well.

1.4 The Babel Hack

A note on JSX and Babel. We need to transform our JSX into JS. It's better to do this as a preprocessing step because it's slower to do in the browser. However since this photo gallery is meant to be a lightweight tutorial, we will avoid the hassle of preprocessing and just do the browser transformation. A short while ago we could have used the JSXTransformer, but that is now deprecated. Babel offers a JSX to Javascript transpiler which I found to be pretty good, but it has some scoping issues that we'll need to get around with a small hack. If you don't like the hack, stick to the preprocessing. :) You can find more info on their website.

So what's the problem? Based on how Babel handles scoping, React components defined in separate files fall out of scope. In order to have access to them, we need to save them globally after they are defined. To do this add the following lines at the end of the JSX file for every component that we define:

// Workaround for babel's limited scoping after migration from JSXTransformer
window.PhotoGallery = PhotoGallery;
window.Photo = Photo;

Hooray! The HTML page should now display a list of photos.

Part 2: Layout Photos Using Photo Blocks

2.1 Photo Block Component

Right now our photos are displayed in a straight line, which isn't very interesting. In order to make the layout look more like a collage, we will insert a new organizational structure between the photos and the gallery called a photo block. Each photo block will receive between 1 to 4 photos and will use a different layout depending on the number of photos.

Here are the four possible layouts:

And here is a photo block defined as a React component:

var PhotoBlock = React.createClass({
  render: function() {
    var numImages = this.props.images.length;

    if( numImages == 1 ) {
      return (
        <div className="photoBlock">
          <Photo className="cell_1" photoURL={ this.props.images[0] } />
        </div>
        );
    }
    if ( numImages == 2 ) {
      return (
        <div className="photoBlock">
          <Photo className="cell_2h" photoURL={ this.props.images[0] } />
          <Photo className="cell_2h" photoURL={ this.props.images[1] } />
        </div>
      );
    }

    // Same pattern for blocks with 3 and 4 photos
  }
});

The block dimensions are 500x500, so each type of layout will size the photos differently. The size of each photo within a layout is static, so we can specify it via a CSS cell type class.

.photoBlock {
  display: inline-block;
  width: 500px;
  height: 500px;
}

.cell_1 {
  width: 496px;
  height: 496px;
}

.cell_2h {
  width: 496px;
  height: 246px;
}

/* See CSS for 3-4 photo layout styles */

You may be wondering how to add the cell classes to the Photo component when it already has a CSS class assigned - photoCell. Since we are assigning this secondary class to the className property of Photo, it will be passed in through the props object. In the Photo component we can then add this class to the already existing one for the div like this:

var Photo = React.createClass({
  render: function() {
    ...
    return (
      <div className={"photoCell " + this.props.className} style={divStyle} />
    );
  }
});

2.2 Display Multiple Photo Blocks

The next step is to modify the PhotoGallery to display PhotoBlocks instead of Photos. We designed the PhotoBlocks to take in a set of images, so it will be the PhotoGallery's job to assign photos to each block. The number of photos that the gallery will assign to each block will depend on a layout config. For the purposes of this tutorial we will use a hardcoded config, but if you want each gallery to have a different layout, just pass it in to the gallery along with the list of photos.

Given the images and the layout config, the PhotoGallery can create and render a list of PhotoBlocks.

var PhotoGallery = React.createClass({
  render: function() {
    var photoBlocks = [];
    var numImages = this.props.photoURLs.length;
    var currentImageIndex = 0;

    while(currentImageIndex < numImages)
    {
      var photoBlockImages = [];

      var numImagesInBlock = this.getNumImagesInPhotoBlock(photoBlocks.length);
      for(var i = 0; i < numImagesInBlock && currentImageIndex < numImages; i++)
      {
        photoBlockImages.push(this.props.photoURLs[currentImageIndex++]);
      }
      photoBlocks.push( <PhotoBlock images={photoBlockImages} /> );

      currentImageIndex += numImagesInBlock;
    }

    return (
      <div className="photoGallery">
        { photoBlocks }
      </div>
    );
  },

  getNumImagesInPhotoBlock: function(blockIndex){
    var layoutConfig = [1,2,3,4];
    return layoutConfig[blockIndex % layoutConfig.length];
  }
});

One nice thing about photo blocks is that they will automatically re-tile as the screen size changes and the gallery will still look like a collage. Here is how our 3 photo layout will change when the screen becomes narrower:

Part 3: Add Full Screen Photo View

If you made it to Part 3, then your photos are displayed in a collage. Woohoo! We're not done yet though. These photos are just previews and constrained by the photo block sizing, but what if someone wants to see the full-sized version? In order to support this, let's show a full size image when the user clicks on a preview.

3.1 Full Photo Component

The FullPhoto component is similar to the Photo component. It receives a photo URL through the props object and has a fullPhoto class attribute that sets the width and height of the image to 100%.

var FullPhoto = React.createClass({
  render: function() {
    if (!this.props.photoURL) {
      return (<div />);
    }
    return (
      <div className="fullPhoto">
        <img src={ this.props.photoURL } />
      </div>
    );
  }
});
.fullPhoto {
  position: fixed;
  overflow: auto;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;

  max-width: 100%;
  max-height: 100%;

  background-color: rgba(20,20,20,0.8);

  z-index: 10;
}

.fullPhoto img {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;

  max-width: 90%;
  max-height: 90%;

  margin: auto;
}

3.2 Display Full Photo View On Click

Here things get a little tricky. When the user clicks on a photo, we need to bring up the full photo view. The question is, who should be responsible for this? Should it be the Photo, the PhotoBlock, or the PhotoGallery?

Eventually, we would like the viewer to navigate to other photos from the full photo view. Since neither the Photo nor the PhotoBlock component has enough information about the album to allow this capability, we will add the full photo view to the gallery. In order to do this the gallery must know which photo to display. We will store the index of this photo in fullScreenPhotoIndex. This variable is declared and initialized in the getInitialState function. Note that when a component has mutable state, it should be stored through the state property so that React can automatically re-render the component as the state changes. This way, we can update the fullScreenPhotoIndex when the viewer navigates to another photo and our gallery will show the right photo automatically.

var PhotoGallery = React.createClass({
  getInitialState: function() {
    return {fullScreenPhotoIndex: -1};
  },
  render: function() {
    ...

    var fullPhotoURL = null;
    var fullPhotoImageIndex = this.state.fullScreenPhotoIndex;
    if (fullPhotoImageIndex >= 0 && fullPhotoImageIndex < numImages) {
      fullPhotoURL = this.props.photoURLs[fullPhotoImageIndex];
    }

    return (
      <div className="photoGallery">
        { photoBlocks }
        <FullPhoto photoURL={fullPhotoURL} />
      </div>
    );
  }
});

Since we want to display the photo on click, we will need to add several click handlers and callbacks.

var PhotoGallery = React.createClass({
  render: function() {
    ...
    while(currentImageIndex < numImages)
    {
     ...
      var photoBlockClickCallback = this.handleBlockClick.bind(this, currentImageIndex);
      var numImagesInBlock = this.getNumImagesInPhotoBlock(photoBlocks.length);
      for(var i = 0; i < numImagesInBlock && currentImageIndex < numImages; i++)
      {
        photoBlockImages.push(this.props.photoURLs[currentImageIndex++]);
      }
      photoBlocks.push( <PhotoBlock images={photoBlockImages} onPhotoClick={photoBlockClickCallback}/> );
    }
    ...
  },

  handleBlockClick: function(blockStartIndex, cellIndex) {
    this.handlePhotoClick(blockStartIndex + cellIndex);
  },

  handlePhotoClick: function(photoIndex) {
    this.setState({fullScreenPhotoIndex: photoIndex});
  },

  ...
});
var PhotoBlock = React.createClass({
  render: function() {
    ...
    if( numImages == 1 )
    {
      return (
        <div className="photoBlock">
          <Photo onClick={this.props.onPhotoClick.bind(this, 0)} className="cell_1" photoURL={ this.props.images[0] } />
        </div>
        );
     }
     ...
  }
});
var Photo = React.createClass({
  render: function() {
    ...
    return (
      <div className={"photoCell "+this.props.className} style={divStyle} onClick={this.props.onClick} />
    );
  }
});

What is happening here? Each PhotoBlock adds an onClick event handler for each of its Photos. It binds the photo's index (relative to the block) to the handler so it can easily determine which photo was clicked. This handler function (onPhotoClick) was actually provided by the gallery, because ultimately the gallery is responsible for displaying the full-screen photo. The gallery calculates which photo to show then updates its internal fullScreenPhotoIndex state.

3.3 Dismiss Full Photo View On Click

When the user clicks on a full photo, we would like to dismiss it and return to the gallery. In order to do this, we must add one more click handler, this time to the full photo component. This click handler will reset the fullScreenPhotoIndex to -1, thus replacing the full photo view with an empty div.

 var FullPhoto = React.createClass({
   render: function() {
     ...
     return (
       <div className="fullPhoto" onClick={this.props.onClick}>
         ...
       </div>
     );
   }
 });

And in the Gallery:

 var PhotoGallery = React.createClass({
   ...
   render: function() {
     ...
     return (
       ...
         <FullPhoto photoURL={fullPhotoCellImageData} onClick={this.handleFullPhotoClick} />
       ...
     );
   },
   handleFullPhotoClick: function() {
     this.setState({fullScreenPhotoIndex: -1});
   }
   ...
 });

Part 4: Arrow Navigation

4.1 PhotoNav Component

Let's add arrow navigation so the user can flip through photos in full screen. Here is the basic nav component. We will reuse the same component for both arrows so it will use class names to distinguish between left and right. It will also take a click callback.

var PhotoNav = React.createClass({
  render: function() {
    return (
      <div className={this.props.className} onClick={this.props.onClick} />
    );
  }
});

The gallery will control the arrows because it has the most information about the state of the photos, but the arrow components themselves will be rendered inside the full photo view because that is where we want them to appear on the screen.

The PhotoNav components will be added as children to the FullPhoto component, and will use class identifiers to distinguish between the left and right arrows. Their click callback will take a delta that will either decrement or increment the fullScreenPhotoIndex depending on which arrow was clicked.

var PhotoGallery = React.createClass({
  render: function() {
    ...
    <FullPhoto photo={fullPhotoViewImageData} clickCallback={this.handleFullPhotoClick}>
      <PhotoNav className="photoNav leftArrow" onClick={this.handlePhotoNavClick.bind(this, -1)}/>
      <PhotoNav className="photoNav rightArrow" onClick={this.handlePhotoNavClick.bind(this, 1)}/>
    </FullPhoto>
    ...
  },
  handlePhotoNavClick: function(delta, evt) {
    var newImageIndex = this.state.fullScreenPhotoIndex + delta;

    if(newImageIndex < 0) {
      newImageIndex = this.props.photoURLs.length - 1;
    } else if (newImageIndex >= this.props.photoURLs.length) {
      newImageIndex = 0;
    }

    this.setState({fullScreenPhotoIndex: newImageIndex});

    // Prevent FullPhoto from receiving clicks and closing view
    evt.preventDefault();
    evt.stopPropagation();
  },
  ...
});

One final detail we need to take care of is to render the children of the the FullPhoto component. We do this by evaluating {this.props.children}. Since we put the PhotoNav components inside the FullPhoto component in the PhotoGallery's render method, they can be accessed by the FullPhoto component through the children array.

var FullPhoto = React.createClass({
  render: function() {
    ...
    return (
      <div className="fullPhoto" onClick={this.props.clickCallback}>
        <img src={ this.props.photoURL } />
        {this.props.children}
      </div>
    );
  }
});

Please refer to the full CSS page for the arrow styling.

Final Thoughts

I hope this tutorial was helpful in getting started with React. You can view the full code solution on GitHub. To see a live working demo with additional features, check out my Photos page.