Reproducing Medium's image zoom
I create an open-source JavaScript library for zooming images like Medium.
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
orscroll
- 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 tonull
)
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:
- be at the first layer on the screen with
z-index
- get the “zoom-out”
cursor
- 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
andheight
- the target
naturalWidth
andnaturalHeight
(actual size of the image) - the
width
andheight
of the resized image as it’s displayed - the
top
andleft
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
-
Scale horizontally:
- get the minimum value between the image full width and the window’s
- divide it by the image width
-
Scale vertically:
- get the minimum value between the image full height and the window’s
- divide it by the image height
- Get the final ratio: the smallest of these two is the one that fits both sides in the screen
Translate
-
Translate horizontally:
- subtract the eventual left offset (
padding
ormargin
) - center the image horizontally considering the new scale
- subtract the eventual left offset (
-
Translate vertically:
- subtract the eventual right offset (
padding
ormargin
) - center the image considering the current scroll and the new scale
- subtract the eventual right offset (
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 thebody
- Reset the
transform
tonone
(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 theclick
event, to allimages
, that is going to handle the zoom in or the zoom out - the method
onScroll
on thescroll
event, to thedocument
, that is going to cancel the zoom - the method
onDismiss
on thekeyup
event, to thedocument
, 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 occursisAnimating
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 forsetTimeout()
.- 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 usekeyup
instead. - You don’t need jQuery.