Reproducing Medium's image zoom

I create an open-source JavaScript library for zooming images like Medium.

12 min read

When I was first introduced to Medium, I was astonished by the simplicity of its user experience. One of its neat features is the image zoom system. Today, I tried to reproduce it as nearly as possible in pure JavaScript.

You can view the demo and check the source code on GitHub. The code in this article will be simplified.

Update. This project was an experimentation that turned into an open-source library. This article is about building the very first version of Medium Zoom. The implementation has slightly changed since version 1.0.0.

Features

Here is the list of the features we want to implement:

  • Scale and translate the image at the center of the screen on click
  • Dismiss the zoom on click, keypress or scroll
  • Make the zoom available to a selection of images
  • Set options such as background color, margin and scroll offset
  • Open the link of the image in a new tab when a meta key is held ( or Ctrl)
  • When no link, open the image source in a new tab when a meta key is held ( or Ctrl)
  • Emit events when the library enters a new state
  • Export the methods as an API

Structuring the plugin

I’ll use ECMAScript 2015 as JavaScript standard and will compile it with a transpiler. The source code is located in the src folder which is converted by Webpack into ES5 with Babel. This creates the minified version of the plugin in the dist directory.

medium-zoom
├── dist
│   ├── medium-zoom.js
│   ├── medium-zoom.min.js
├── src
│   ├── medium-zoom.css
│   └── medium-zoom.js
├── package.json
└── webpack.config.js

We want our stylesheet to be included in the script we write. We use the css-loader module for Webpack to do that. It tells our script that we’ll depend on these styles and css-loader is going to inject them in the head of the HTML page. This way, the user will only have to include the script, no stylesheet. Besides, the module minifies the CSS for us and add prefixes for browser support.

import 'medium-zoom.css'

The plugin is contained in a function called mediumZoom which takes a selector and an object of options as arguments. We use an object for the later because they are optional and unordered.

const mediumZoom = (selector, options = {}) => {
  const images = document.querySelectorAll(selector)
  let target = null
}

In this basic implementation, we are using:

  • An array-like of images
  • An object of options
  • A target representing the current image zoomed (initially set to null)

We now need to process these images.

Selecting images

By default, we’d like to apply the effect on all images that can be zoomed.

An image can be zoomed if it’s been resized smaller than its actual size (with a class, some styles or width and height HTML attributes). An img has a property called naturalWidth which corresponds to its full size.

To get all the images that are not already at their full size, we need to iterate over all of them and filter them.

const mediumZoom = (selector, options = {}) => {
  // ...

  const isSupported = (elem) => elem.tagName === 'IMG'  const isScaled = (img) => img.naturalWidth !== img.width
  const images =    [...document.querySelectorAll(selector)].filter(isSupported) ||    [...document.querySelectorAll('img')].filter(isScaled)
  // ...
}

Since querySelectorAll returns an array-like, we use the spread operator to convert the result to an array.

In order for these images to look zoomable, we need to add a CSS class with a “zoom-in” pointer icon and prepare a transition.

.medium-zoom-image {
  cursor: zoom-in;
  transition: all 300ms;
}

Let’s attach this CSS class to the images we’ve filtered.

const mediumZoom = (selector, options = {}) => {
  // ...

  images.forEach((image) => {    image.classList.add('medium-zoom-image')  })}

Adding the overlay

On click on an image, an overlay should hide the content of the page. This overlay is by default white, but can be overriden by the user.

It needs to take the full screen and to have a fixed position. The opacity will increase from 0 to 1 in 300ms. Performance-wise, we promote the overlay to its own layer with the will-change property.

.medium-zoom-overlay {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: #fff;
  opacity: 0;
  transition: opacity 300ms;
  will-change: opacity;
}

The overlay is created only once and is added/removed from the DOM at every click on an image.

const mediumZoom = (selector, options = {}) => {
  // ...

  const { background = '#fff' } = options

  const overlay = document.createElement('div')  overlay.classList.add('medium-zoom-overlay')  overlay.style.backgroundColor = background}

Transitioning

Styling the elements

Before zooming on the target image, we need to:

  • Append the overlay to hide the page content
  • Add the open class to the body to trigger the overlay animation
  • Add the open class to the image
const mediumZoom = (selector, options = {}) => {
  // ...

  const zoom = () => {
    scrollTop = document.body.scrollTop

    document.body.appendChild(overlay)

    requestAnimationFrame(() => {      document.body.classList.add('medium-zoom--open')    })
    target.classList.add('medium-zoom-image--open')

    animateTarget()
  }
}

We need to use requestAnimationFrame here to fire the animation. The browser will add the class to the body before the next repaint — which is the overlay.

When the user clicks on the image, the class medium-zoom--open is added to the body. The overlay, as a child of the body, will fade in and get the “zoom-out” cursor.

.medium-zoom--open .medium-zoom-overlay {
  cursor: zoom-out;
  opacity: 1;
}

.medium-zoom-image--open {
  position: relative;
  z-index: 999;
  cursor: zoom-out;
  will-change: transform;
}

The target of the event (the image clicked) will be added the medium-zoom-image--open class. This one allows the image to:

  1. be at the first layer on the screen with z-index
  2. get the “zoom-out” cursor
  3. be promoted and improve performance with will-change (creates its own layer)

Computing the scale and the translation

Here comes the mathematics. We’ll need to extract:

  • the window’s width and height
  • the target naturalWidth and naturalHeight (actual size of the image)
  • the width and height of the resized image as it’s displayed
  • the top and left offsets of the target
const mediumZoom = (selector, options = {}) => {
  // ...

  const animateTarget = () => {
    const windowWidth = window.innerWidth
    const windowHeight = window.innerHeight

    const viewportWidth = windowWidth - options.margin * 2
    const viewportHeight = windowHeight - options.margin * 2

    const { naturalWidth, naturalHeight } = target
    const { width, height, top, left } = target.getBoundingClientRect()

    const scaleX = Math.min(naturalWidth, viewportWidth) / width
    const scaleY = Math.min(naturalHeight, viewportHeight) / height
    const scale = Math.min(scaleX, scaleY)

    const translateX = (-left + (viewportWidth - width) / 2) / scale
    const translateY =
      (-top + (viewportHeight - height) / 2 + options.margin) / scale

    target.style.transform = `scale(${scale})
    translate3d(${translateX}px, ${translateY}px, 0)`
  }
}

The very useful getBoundingClientRect() method is applied to the target to get half of the information we needed. We use object destructuring from ES2015 to easily get all the properties.

Once we have these properties, we can compute:

Scale

  1. Scale horizontally:

    • get the minimum value between the image full width and the window’s
    • divide it by the image width
  2. Scale vertically:

    • get the minimum value between the image full height and the window’s
    • divide it by the image height
  3. Get the final ratio: the smallest of these two is the one that fits both sides in the screen

Translate

  1. Translate horizontally:

    • subtract the eventual left offset (padding or margin)
    • center the image horizontally considering the new scale
  2. Translate vertically:

    • subtract the eventual right offset (padding or margin)
    • center the image considering the current scroll and the new scale

Style

Finally, we fire the animation by adding the styles to the target.

Dismissing the zoom

When there’s a target at the moment of the click, that means we have to zoom out. Here is the process to follow:

  • Remove the open class from the body
  • Reset the transform to none (will play the animation backward)
  • Remove the overlay from the DOM
  • Remove the open class from the target image
  • Reset the target to null
const mediumZoom = (selector, options = {}) => {
  // ...

  const zoomOut = () => {
    if (!target) return

    isAnimating = true
    document.body.classList.remove('medium-zoom--open')
    target.style.transform = 'none'

    target.addEventListener('transitionend', onZoomOutEnd)
  }

  const onZoomOutEnd = () => {
    if (!target) return

    document.body.removeChild(overlay)
    target.classList.remove('medium-zoom-image--open')

    isAnimating = false
    target.removeEventListener('transitionend', onZoomOutEnd)
    target = null
  }
}

The relevant part here is the event listener on transitionend that fires when the animation of the attached object is over.

This way, we remove the overlay from the DOM only when the image has been translated entirely to its original position.

Handling events

Medium’s zoom handles mouse, keyboard, and scroll events. We need to listen to all these events to support all interactions.

const mediumZoom = (selector, options = {}) => {
  // ...

  images.forEach((image) => {
    image.addEventListener('click', onClick)
  })

  document.addEventListener('scroll', onScroll)
  document.addEventListener('keyup', onDismiss)
}

We attach:

  • the method onClick on the click event, to all images, that is going to handle the zoom in or the zoom out
  • the method onScroll on the scroll event, to the document, that is going to cancel the zoom
  • the method onDismiss on the keyup event, to the document, that is going to cancel the zoom if the key is esc or q

Update. Since Medium Zoom 1.0.0, a single event listener attached on the document.body object handles all the image interactions.

Click

When the user holds a meta key ( or Ctrl), we need to stop the plugin’s execution and to open the link wrapping the image, or the image source in a new tab. Otherwise, we trigger the zoom.

const mediumZoom = (selector, options = {}) => {
  // ...

  const onClick = (event) => {
    if (event.metaKey || event.ctrlKey) {
      return window.open(
        event.target.getAttribute('data-original') ||
          event.target.parentNode.href ||
          event.target.src,
        '_blank'
      )
    }

    event.preventDefault()

    // ...
  }
}

Keyboard

We have to dismiss the zoom only if the key pressed’s role is to cancel (esc and q).

const mediumZoom = (selector, options = {}) => {
  const KEY_ESC = 27
  const KEY_Q = 81
  const CANCEL_KEYS = [KEY_ESC, KEY_Q]

  // ...

  const onDismiss = (event) => {
    const keyPressed = event.keyCode || event.which

    if (CANCEL_KEYS.includes(keyPressed)) {
      zoomOut()
    }
  }
}

If it is a cancel key, we call the zoomOut method.

Scroll

We also need to dismiss the zoom when the user scrolls. However, we should wait for a certain number of pixels to be scrolled to dismiss it. We, therefore, need to use some new variables:

  • scrollTop defines the scroll position when the zoom occurs
  • isAnimating defines if the zoom is being animated (in which case we don’t check the scroll offset)
const mediumZoom = (selector, options = {}) => {
  // ...

  let scrollTop = 0
  let isAnimating = false

  const { scrollOffset = 40 } = options

  const zoom = () => {
    scrollTop = document.body.scrollTop
    // ...
  }

  const onScroll = () => {
    if (isAnimating || !target) return

    const scrolling = Math.abs(scrollTop - document.body.scrollTop)

    if (scrolling > options.scrollOffset) {
      zoomOut()
    }
  }
}

The default value of scrollTop is 0. Every time we zoom on an image, the variable takes the value of the current document.body.scrollTop.

Now, we compare the absolute value of the window scroll and the one stored when the user initially zoomed on the image.

Finally, we want this scroll offset to be customizable by the user. We need to add it to the constructor’s options.

Emitting events

The library should emit an event every time it enters a new state. This way, the user can detect when:

  • the image is zoomed
  • the zoom animation has completed
  • the image is zoomed out
  • the zoom out animation has completed

This can be done quite easily in vanilla JavaScript with the dispatchEvent method.

const mediumZoom = (selector, options = {}) => {
  // ...

  const zoom = () => {
    // ...
    const event = new Event('show')
    target.dispatchEvent(event)
  }
}

The same goes for the zoomOut, onZoomEnd and onZoomOutEnd methods.

Exporting the methods

The user may need to trigger the zoom dynamically, without clicking on the image. We, therefore, need to make some methods available outside of the file.

const mediumZoom = (selector, options = {}) => {
  // ...

  return {    show: zoom,    hide: zoomOut,  }}

We can now call the plugin this way:

const button = document.querySelector('#btn-zoom')
const zoom = mediumZoom('#image')

button.addEventListener('click', () => zoom.show())

Result

You can head over the demo to see the result. Let me know if it’s close enough to Medium’s image zoom. Go to the GitHub repository to see the actual code.

What I learned

  • Webpack is very powerful. PostCSS, load CSS, minification and automation.
  • npm scripts can do a lot. Lint, pre-build, build, watch, etc.
  • Passing an object as argument is a must for plugins. The user doesn’t have to know what is the order of the arguments.
  • The spread operator is very useful. It prevents you from manipulating the prototype and calling functions.
  • transitionend is awesome. That’s one less use case for setTimeout().
  • Emitting events is quite easy. It’s straightforward to emit an event every time the state changes.
  • Chrome disables keypress on esc. Safari doesn’t. You have to use keyup instead.
  • You don’t need jQuery.