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.

View demo View on GitHub

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
  • Attach the zoom to a selection of images
  • Set options such as background color, margin and scroll offset
  • Open the image’s link 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 new states
  • Expose the methods to the external API

Note: the code in this article will be simplified. Go see the GitHub repo for actual code.

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
├── demo
│   ├── index.html
│   └── styles.css
├── dist
│   ├── medium-zoom.js
│   ├── medium-zoom.js.map
│   ├── medium-zoom.min.js
│   └── medium-zoom.min.js.map
├── package.json
├── src
│   ├── medium-zoom.css
│   └── medium-zoom.js
└── webpack.config.js

The plugin is gathered 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 */
} = {}) => {
  require('./medium-zoom.css')

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

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.

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 their sizes.

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)

  // ...

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

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, let’s change the pointer to a “zoom-in” icon and set the transform property as “transitionable”.

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

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 overwritten 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 300 miliseconds. 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, {
  background = '#fff'
} = {}) => {
  // ...

  const options = {
    background
  }

  const createOverlay = () => {
    const overlay = document.createElement('div')
    overlay.classList.add('medium-zoom-overlay')
    overlay.style.backgroundColor = options.background

    return overlay
  }

  const overlay = createOverlay()
}

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 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 a “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 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 (the actual size of the image)
  • the width and height of the image relative to the viewport (the image’s size as it’s displayed)
  • the top and left offsets of the target relative to the viewport
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 data, we compute the style properties:

  • Scale:
    1. Scale horizontally:
      • get the minimum value between the image’s full width and the window’s
      • divide it by the image width
    2. Scale vertically:
      • get the minimum value between the image’s 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 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 completely 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, in order to handle the zoom in or the zoom out
  • the method onScroll on the scroll event to the document, in order to cancel the zoom
  • the method onDismiss on the keyup event to the document, in order to cancel the zoom if the key is esc or q

Click

When the user holds a meta key ( or Ctrl), we want 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 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 role of the key pressed 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 amount of pixels to be scrolled to dismiss. 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, {
  // ...
  scrollOffset = 48
} = {}) => {
  // ...
  let scrollTop = 0
  let isAnimating = false

  const options = {
    // ...
    scrollOffset
  }

  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.

Additionally, 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 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 expose some methods to make them available outside of the function.

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. Tell me if it’s close enough to Medium’s image zoom!

To see the actual code, go to the GitHub repo.

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.

Thanks Medium for giving me inspiration!