Presenting: Fluidbox

Recreating and improving Medium’s default lightbox module

Terry Mun
Terry Mun
Dec 28, 2013 · 13 min read

Author’s note: the most recent version, 1.3, is published on January 4th, 2014.

I love writing on Medium. The typography is nothing short of fantastic. The editor is simple, minimalist and distraction-free. The handling of images is perfectly done.

The last point has especially piqued my interest. I adore the smooth transition offered by Medium’s lightbox module — no disruptive modal window, and opening/closing of the lightbox is intuitive and straightforward.

Image for post
Image for post
An animated gif demonstrating the functionality of Fluidbox. Captured using LICEcap.

So I tasked myself with a little challenge — replicate it, and improve on it, if possible. Where do we start?

Peeping into Chrome’s inspector, I realized that Medium utilized a simple but powerful solution — CSS transitions and transforms. Scale and translate are the corner stone for the latter technique, while transitions simply add a slick, smooth easing metamorphosis between the thumbnail versions and their larger counterparts.

So, I named it Fluidbox.

Here is the full-fledged and functional demo hosted on JSFiddle. Try it out. Fiddle with it. And if you’re interested, I have taken the liberty to explain how I have managed to replicate and improve upon this functionality.

How does Fluidbox compare to Medium’s default lightbox?

Changelog

As of January 3, 2014, this project has been ported over to GitHub and released as my first jQuery plugin.

HTML & CSS

The markup is extremely straightforward. No fancy hat-tricks, and definitely div-itis free. I have decided to use the HTML5 data- attribute to mark anchor elements that I want Fluidbox to work.

<a href="" title="" data-fluidbox>
<img src="" title="" alt="" />
</a>

The CSS is a wee bit complicated. First of all, we address the anchor element that we demarcate for Fluidbox functionality:

a[data-fluidbox] {
background-color: #eee;
border: none;
cursor: -webkit-zoom-in;
cursor: -moz-zoom-in;
margin-bottom: 1.5rem;
}
a[data-fluidbox].fluidbox-opened {
cursor: -webkit-zoom-out;
cursor: -moz-zoom-out;
}
a[class^='float'] {
margin: 1rem;
margin-top: 0;
width: 33.33333%;
}
a.float-left {
float: left;
margin-left: 0;
}
a.float-right {
float: right;
margin-right: 0;
}

Now we deal with the image element in the anchor element itself. I have chosen to hide the images and then fade them in once they are done loading.

a[data-fluidbox] img {
display: block;
margin: 0 auto;
opacity: 0;
max-width: 100%;
transition: all .25s ease-in-out;
}

Then, we work with elements that are created dynamically by jQuery later on. I’ll style them now, and explain what they do:

#fluidbox-overlay {
background-color: rgba(255,255,255,.85);
cursor: pointer;
cursor: -webkit-zoom-out;
cursor: -moz-zoom-out;
display: none;
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 500;
}
.fluidbox-wrap {
background-position: center center;
background-size: cover;
margin: 0 auto;
position: relative;
z-index: 400;
transition: all .25s ease-in-out;
}
.fluidbox-opened .fluidbox-wrap {
z-index: 600;
}
.fluidbox-ghost {
background-size: cover;
background-position: center center;
position: absolute;
transition: all .25s ease-in-out;
}

jQuery

The dependency needed for our jQuery function to work is the imagesloaded plugin. The plugin allows me to listen in on the loading of all the images used for Fluidbox — it makes sense because we have to wait for images to be loaded in order to access their dimesnions for calculations later.

As I feel most comfortable working with jQuery, Fluidbox has been written to utilize the framework. Remember that all of the following code has to be placed within the DOM ready event, i.e.:

$(function (){
// Everything
});

Step 1: General housekeeping and defining functions

Some general housekeeping function is needed: (1) declare some much-needed global variables, (2) appending the overlay to the body, (3) defining the closeFb function to close any opened Fluidbox and (4) defining the function that calculates positioning of Fluidbox.

// Global variables
var $fb = $('a[data-fluidbox');
vpRatio; // To store viewport aspect ratio
// Add class to all Fluidboxes
$fb.addClass('fluidbox');
// Create fluidbox modal background
$('body').append('<div id="fluidbox-overlay"></div>');
// Functions:
// 1. to close any opened Fluidbox
// 2. to position Fluidbox dynamically
var closeFb = function (){
$('a[data-fluidbox].fluidbox-opened').trigger('click');
},
positionFb = function ($activeFb){
// Get elements
var $img = $activeFb.find('img'),
$ghost = $activeFb.find('.fluidbox-ghost');

// Calculate offset and scale
var offsetY = $(window).scrollTop()-$img.offset().top+0.5*($img.data('imgHeight')*($img.data('imgScale')-1))+0.5*($(window).height()-$img.data('imgHeight')*$img.data('imgScale')),
offsetX = 0.5*($img.data('imgWidth')*($img.data('imgScale')-1))+0.5*($(window).width()-$img.data('imgWidth')*$img.data('imgScale')) - $img.offset().left,
scale = $img.data('imgScale');

// Animate wrapped elements
// Parse integers:
// 1. Offsets can be integers
// 2. Scale is rounded to nearest 2 decimal places
$ghost.css({ 'transform': 'translate('+parseInt(offsetX*10)/10+'px,'+parseInt(offsetY*10)/10+'px) scale('+parseInt(scale*1000)/1000+')' });
}
// The following events will force FB to close
// ... when the opqaue overlay is clicked upon
$('#fluidbox-bg').click(closeFb);

It is important to parse the calculated values of offsetX, offsetY and scale. Due to issues with floating point calculation in JavaScript, we might not get the expected value of 0 for some calculations even though it should be — instead, we get an epsilon value (e.g. 2.01e-14) which will invalidate our CSS styling. offsetX and offsetY can be rounded to the nearest one decimal place, For scale, however, we will round it to the nearest three decimal places.

You can also fire the closeFb() function for other event handlers. The reason why the calculations for dynamic positioning of Fluidbox is wrapped in a function is to allow the same calculations to be called when different events are detected — namely the window resize event (when viewport size and/or orientation will change, therefore determining how images should be scaled) and the click event.

How do we compute the correct values,
to translate and scale the ghost image?

The challenging part would be calculating the amount we need to translate the ghost element along the x and y axes such that it is centered. I have used a diagram below to better illustrate my calculations. I highly encourage you to view the high resolution animated gif (83kb, 2000*600px).

Image for post
Schematics of the calculations.

So what I basically did in that very complicated formula for offsetY is:

A similar operation is performed to calculated offsetX, but by skipping the first step since horizontal scrolling is uncommon, and therefore I have opted to assume that scrollLeft will default to a value of zero.

Step 2: Setting up Fluidbox

The rest of the code hereon will be wrapped within the .done() function to ensure that our calculations and event handler handing will be performed only when images are done loading. The imagesLoaded plugin comes with support for jQuery deferred objects, which is a definite plus.

$fb.imagesLoaded().done(function (){
// All calculations and event handler binding
});

2.1: Create dynamic elements

Once images are done loading, we can start getting busy with creating dynamic elements — we wrap all contents in the anchor element with .fluidbox-wrap, and then create a ghost sibling, .fluidbox-ghost, for the image element. The expected outcome will look like this:

<a href="..." data-fluidbox>
<div class="fluidbox-wrap">
<img src="..." alt="..." title="..." />
<div class="fluidbox-ghost"></div>
</div>
</a>

This is easily done by a chaining methods in jQuery:

$fb
.wrapInner('<div class="fluidbox-wrap" />')
.find('img')
.css({ opacity: 1 })
.after('<div class="fluidbox-ghost" />');

What the code snippet does above is easily explained as:

2.2: Listen to window resize to perform calculations

The tricky part of getting that all our calculations rely on the image dimension — from determining the scale factor to the dimension of the ghost element, we need to access the computed dimension of the image element. But it changes dynamically with viewport size, so we have to perform these within the window resize event. The annotated code blocks, (#1 through #4), will be explained shortly after.

// Listen to resize event for calculations
$(window).resize(function (){

// Get viewport aspect ratio (#1)
vpRatio = $(window).width() / $(window).height();

// Get dimensions and aspect ratios
$fb.each(function (){
var $img = $(this).find('img'),
$ghost = $(this).find('.fluidbox-ghost'),
$wrap = $(this).find('.fluidbox-wrap'),
data = $img.data();

// Save image dimensions as jQuery object (#2)
data.imgWidth = $img.width();
data.imgHeight = $img.height();
data.imgRatio = $img.width() / $img.height();

// Resize ghost element (#3)
$ghost.css({
width: $img.width(),
height: $img.height(),
top: $img.offset().top - $wrap.offset().top,
left: $img.offset().left - $wrap.offset().left
});

// Calculate scale based on orientation (#4)
if(vpRatio > data.imgRatio) {
data.imgScale = $(window).height()*.95 / $img.height();
} else {
data.imgScale = $(window).width()*.95 / $img.width();
}
});
}).resize();

The grouped blocks of code are written so as to perform specific functions, which I will elabourate on in a minute. With the exception fo the viewport aspect ratio, which holds true for all instances as long as the viewport is not resized, all calculations are made on a per image basis.

How should we scale the image,
so that it fits within the viewport?

Now we get to the the complicated part — #4, calculating the scale factor. Basically, what we want to do is to scale the image such that it still fits within the viewport. Therefore, the scale factor has to be calculated with respect to the widest axis, depending on the orientation of the screen and the image. The logic is simple:

It turns out that this logic does not rely on the explicit identification of image or viewport orientation. It simply works. Elegantly, and beautifully. In this case, I have decided that I should not fill out the entire viewport, so I scaled down the calculated scale by a factor of 0.95, i.e. the widest dimension of the final scaled image should be 95% of the viewport width or height, and not more.

2.3 Triggering Fluidbox

With all groundwork on calculations being done, the only job left is to bind the click event to each Fluidbox instance. However, we also have to know which state each Fluidbox instance is in — is it opened, or is it closed? We store that state in yet another jQuery data object.

// Bind click event
$fb.click(function (e){

// Variables
var $img = $(this).find('img'),
$ghost = $(this).find('.fluidbox-ghost'),
$wrap = $(this).find('.fluidbox-wrap');

if($(this).data('fluidbox-state') == 0 || !$(this).data('fluidbox-state')) {
// State: Closed
// Action: Open Fluidbox
// (Code Block #1)
} else {
// State: Opened
// Action: Close Fluidbox
// (Code Block #2)
}
});

You can see from the code above that I have chosen to store the state of each Fluidbox instance in a jQuery data object called fluidbox-state. When this data object is non-existent or is equivalent to 0, it means the Fluidbox is closed, and we proceed to open it. The reverse happens for the alternative scenario.

The content of code blocks #1 and #2 simply performs the function of opening and closing the Fluidbox respectively.

To open the Fluidbox, we perform the following:

The following code goes into code block #1:

$(this)
.data('fluidbox-state', 1)
.removeClass('fluidbox-closed')
.addClass('fluidbox-opened');
// Show overlay
$('#fluidbox-overlay').fadeIn();
// Set thumbnail image source as background image first.
// We also show the ghost element
$ghost.css({
'background-image': 'url('+$img.attr('src')+')',
opacity: 1
});
// Hide original image
$img.css({ opacity: 0 });

// Preload ghost image
var ghostImg = new Image();
ghostImg.onload = function (){ $ghost.css({ 'background-image': 'url('+$activeFb.attr('href')+')' }); };
ghostImg.src = $(this).attr('href');

// Position Fluidbox
positionFb($(this));

The calculation is all handled by the positionFb() function. We have to pass the context so that the calculation will be performed on the correct Fluidbox object — in this case, we simply pass $(this), which is the Fluidbox the user has clicked on.

To close the Fluidbox, we basically reverse the action we have done previously. The following code goes into code block #2. The code is simple, as well as need to do is:

// Switch state
$(this)
.data('fluidbox-state', 0)
.removeClass('fluidbox-opened')
.addClass('fluidbox-closed');

// Hide overlay
$('#fluidbox-overlay').fadeOut();

// Show original image
$img.css({ opacity: 1 });

// Hide ghost image
$ghost.css({ opacity: 0 });

// Reverse animation on wrapped elements
$ghost
.css({ 'transform': 'translate(0,0) scale(1)' })
.one('webkitTransitionEnd MSTransitionEnd oTransitionEnd transitionend', function (){
// Wait for transntion to run its course first
$ghost.css({ opacity: 0 });
});

In order to know when the transitions for CSS transform changes have been completed, we can listen on the transitionend event. It is rather widely supported in many modern browsers, although vendor prefixes are needed in order to maximize compatibility. Note the spelling of the event, which varies across browsers, adding to the bedlam of vendor prefixes.

And you’re done! You can view the demo in its entirety, and even fiddle around with the code itself, too.

Final Words

There are some drawbacks with Fluidbox, and the limitations may restrict its implementation in any situation that calls for a lightbox.

One major drawback is that the thumbnail and the actual image have to be of identical aspect ratio, e.g. the thumbnail should be a scaled down version of the actual image (but not necessarily so). It is also perfectly fine to use the actual image as the thumbnail itself, since CSS will simply scale it down to a maximum width of 100% of the wrapper element. However, you might want to use a low resolution image to cater to mobile devices and visitors who have restricted bandwidth, or are on metered schemes. Therefore, this rules out the possibility of using square thumbnails for landscape or portrait photos. I did consider an implementation for this feature, but it proved to be too difficult with my current limited skills.

If you have found any bugs or issues with the CodePen demo or in the codes embedded in this article, do leave a note to allow me to fix the problems. Feedback will make this demo better for everyone.

A GitHub repository has been created to port this experiment into a full-fledged jQuery plugin. The demo on CodePen will no longer be actively maintained, but it will remain perfectly functional.

If you have enjoyed reading this article, do consider subscribing to the Coding & Design collection, curated by yours truly.

Last, but not least, have a happy new year!

Coding & Design

All about coding and designing for the web — from HTML…

Terry Mun

Written by

Terry Mun

Amateur photographer, enthusiastic web developer, whimsical writer, recreational cyclist, and PhD student in molecular biology. Sometimes clumsy. Aarhus, DK.

Coding & Design

All about coding and designing for the web — from HTML, jQuery and CSS to the deep recesses of the art of coding.

Terry Mun

Written by

Terry Mun

Amateur photographer, enthusiastic web developer, whimsical writer, recreational cyclist, and PhD student in molecular biology. Sometimes clumsy. Aarhus, DK.

Coding & Design

All about coding and designing for the web — from HTML, jQuery and CSS to the deep recesses of the art of coding.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store