React Pose has been deprecated in favour of Framer Motion. Read the upgrade guide

Tutorial: Medium-style image zoom

Medium have an beautiful zoom effect on their images. When clicked, they pop out of the page as a white background fades in behind them. Then, if clicked again, or if a user scrolls away, they pop back into place.

Take a look:

In this tutorial, we’ll learn how to achieve this same effect using Pose for React.

Setup

To get started, fork this CodeSandbox template.

It contains a mock article that contains a couple of images. These are being rendered via the component we’re going to work on, ZoomImage.

Open components/ZoomImage.js, and let’s get started!

State

First, we need to create state so we know whether the image is zoomed or not. At the top of the ZoomImage class, add the following:

state = { isZoomed: false };

Of course, this state is useless on its own. We’re going to need a couple of functions to set the zoom status. On the next line, add the following zoomIn and zoomOut methods:

zoomIn() {
  this.setState({ isZoomed: true });
}

zoomOut() {
  this.setState({ isZoomed: false });
}

Finally, we want to toggle the zoomed state when someone clicks the image container (as this will also later contain the white background):

<div
  class="image-frame"
  onClick={() => this.state.isZoomed ? this.zoomOut() : this.zoomIn()}
  style={{ width: imageWidth, height: imageHeight }}
>

Now, when the image is clicked, the component will change zoom status. But we’re not responding to this in our render function. Let’s make some animations!

Image zoom animation

When the image zooms in, it needs to animate from its place in the document, smoothly into the center of the screen. To do this, we’re going to use Pose’s FLIP capabilities.

You can read the gritty details about FLIP in this blog post by Paul Lewis. In essence, it’s a way of performantly animating between two states that would otherwise be expensive, for instance where position, top, width, or other layout-changing properties have changed.

In Pose, you simply have to add flip: true to a pose, and it’ll automatically perform the usually complicated steps to perform this animation.

Import Pose for React:

import posed from 'react-pose';

Now make a posed img component:

const Image = posed.img();

We’re going to provide the component two poses, one for each zoom state: zoomedIn and zoomedOut.

Our zoomedIn pose is going to set position: fixed and every positional prop to 0. This will pop the content out of the layout and lock it to the viewport.

In our styles.css file, img has a style of margin: auto which centers the image when it’s being stretched across the screen in this way.

const Image = posed.img({
  zoomedIn: {
    position: 'fixed',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
    flip: true
  }
});

zoomedOut sets position: static to pop it back into the DOM, as well as setting width and height to auto to make it fill its layout container:

const Image = posed.img({
  zoomedIn: {
    position: 'fixed',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
    flip: true
  },
  zoomedOut: {
    position: 'static',
    width: 'auto',
    height: 'auto',
    flip: true
  }
});

We’ve now got our posed img component fully configured. Replace the img component in the render function with it:

<Image {...props} />

To animate Image between the two poses, we need to provide it a pose property.

At the top of the render function, set our pose:

const { isZoomed } = this.state;
const pose = isZoomed ? 'zoomedIn' : 'zoomedOut';

And provide it to Image:

<Image pose={pose} {...props} />

Now, when we click our image, it zooms in and out!

I find the automatically generated animation a little bouncy for this purpose. We can define a new transition with a ease curve generated at Lea Verou’s cubic bezier generator.

const transition = {
  duration: 400,
  ease: [0.08, 0.69, 0.2, 0.99]
};

Provide this as a transition prop to both poses, and the animation becomes a little slicker.

Background animation

That’s the (usually) difficult bit out of the way. It’s looking pretty good but the Medium example fades a background in behind the image as it zooms in and out.

Make a new posed component called Frame:

const Frame = posed.div();

In our styles.css add a new rule for .frame. We’re going to make the background of this frame white, and set translateZ(0) to ensure its fade animation is hardware-accelerated:

.frame {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: none;
  background: white;
  transform: translateZ(0);
}

And now add Frame as a sibling of Image, also passing it the pose prop:

<Frame pose={pose} class="frame" />
<Image pose={pose} {...props} />

Now we can animate it! We just want to fade the overlay in and out, so first add those poses:

const Frame = posed.div({
  zoomedIn: { opacity: 1 },
  zoomedOut: { opacity: 0 }
});

By itself this won’t do anything, as we’ve got display set to none in the CSS.

For this we can use the applyAtStart and applyAtEnd props. They allow you to define styles to set at the start and at the end of the pose transition, respectively.

const Frame = posed.div({
  zoomedIn: {
    applyAtStart: { display: 'block' },
    opacity: 1
  },
  zoomedOut: {
    applyAtEnd: { display: 'none' },
    opacity: 0
  }
});

Now your background will fade in and out behind the image as it zooms in!

Scroll to zoom out

The original Medium image zoom has a nice feature where if a user starts scrolling, the image zooms out back into its original place.

We can accomplish the same thing by adding a 'scroll' event listener to zoomIn:

zoomIn() {
  window.addEventListener('scroll', this.zoomOut);
  this.setState({ isZoomed: true });
}

By itself, this isn’t going to work. When this.zoomOut is called, it’ll be in the execution context of the event caller rather than our React component. We can bind zoomOut to our component by changing it to an arrow function:

zoomOut = () => {
  this.setState({ isZoomed: false });
};

Finally, we need to remove the event listener when a user does zoom out:

zoomOut = () => {
  window.removeEventListener('scroll', this.zoomOut);
  this.setState({ isZoomed: false });
};

Conclusion

Here’s our finished example:

There’s plenty of fun things you can do to improve accessibility and aesthetics.

Have a think about:

  • Closing the image via the esc key
  • Changing the background animation. You could even incorporate SVGs.
  • Adding a “scroll delay” where a user has to scroll a minimum distance before we close the image.
  • Changing the cursor to show a zoom in or zoom out icon.