Reproducing Medium's image zoom
The geometry behind a 3KB image zoom that feels native.
· 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 closely 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 experiment 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 features we want to implement:
- Scale and translate the image to 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 there is 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 the JavaScript standard and compile it with a transpiler. The source code lives in the src folder, which Webpack converts to 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.jsWe 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 to depend on these styles, and css-loader injects them into the head of the HTML page. This way, the user only has to include the script, not a stylesheet. The module also minifies the CSS for us and adds prefixes for browser support.
import 'medium-zoom.css';The plugin is contained in a function called mediumZoom, which takes a selector and an options object as arguments. We use an object for the options because they are optional and unordered.
const mediumZoom = (selector, options = {}) => {
const images = document.querySelectorAll(selector);
let target = null;
};In this basic implementation, we use:
- An array-like collection of images
- An object of options
- A
targetrepresenting the currently zoomed image (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 images that are not already at their full size, we need to iterate over 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 object, 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
When clicking an image, an overlay should hide the content of the page. This overlay is white by default, but the user can override it.
It needs to cover the full screen and use 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 on every image click.
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
openclass to the body to trigger the overlay animation - Add the
openclass 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 animates 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 clicked image) will get the medium-zoom-image--open class. This allows the image to:
- be at the first layer on the screen with
z-index - get the “zoom-out”
cursor - be promoted with
will-changeto improve performance (creates its own layer)
Computing the scale and the translation
Here comes the mathematics. We'll need to extract:
- the window's
widthandheight - the target
naturalWidthandnaturalHeight(actual size of the image) - the
widthandheightof the resized image as it's displayed - the
topandleftoffsets 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 need. 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 width
- divide it by the image width
-
Scale vertically:
- get the minimum value between the image full height and the window height
- divide it by the image height
-
Get the final ratio: the smallest of these two is the one that fits both sides on the screen
Translate
-
Translate horizontally:
- subtract the eventual left offset (
paddingormargin) - center the image horizontally considering the new scale
- subtract the eventual left offset (
-
Translate vertically:
- subtract the eventual top offset (
paddingormargin) - center the image considering the current scroll and the new scale
- subtract the eventual top 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
openclass from thebody - Reset the
transformtonone(will play the animation backward) - Remove the overlay from the DOM
- Remove the
openclass 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 transitionend event listener, which fires when the animation on 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 of them to support every interaction.
const mediumZoom = (selector, options = {}) => {
// ...
images.forEach((image) => {
image.addEventListener('click', onClick);
});
document.addEventListener('scroll', onScroll);
document.addEventListener('keyup', onDismiss);
};We attach:
- the
onClickmethod on theclickevent for allimages, which handles zooming in or out - the
onScrollmethod on thescrollevent for thedocument, which cancels the zoom - the
onDismissmethod on thekeyupevent for thedocument, which cancels the zoom if the key is esc or q
Update. Since Medium Zoom 1.0.0, a single event listener attached on the
document.bodyobject handles all the image interactions.
Click
When the user holds a meta key (⌘ or Ctrl), we need to stop the plugin's execution and 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 is meant 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 until a certain number of pixels have been scrolled before dismissing it. We therefore need to use some new variables:
scrollTopdefines the scroll position when the zoom occursisAnimatingdefines whether 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 into 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 into 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 to 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, CSS loading, minification, and automation.
- npm scripts can do a lot. Lint, pre-build, build, watch, etc.
- Passing an object as an argument is a must for plugins. The user doesn't have to know the order of the arguments.
- The spread operator is very useful. It prevents you from manipulating prototypes and calling functions.
transitionendis 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
keypresson esc. Safari doesn't. You have to usekeyupinstead. - You don't need jQuery.