Presenting: Fluidbox

Recreating and improving Medium’s default lightbox module

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.

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?

  1. It works on mobile. Medium has disabled the feature on mobile devices probably due to the lack of need to magnify images in a small screen, but I choose to implement it for smaller screen sizes. You can disable the effect easily, by adding a conditional statement listening on screen resolution upon the firing of the resize event.
  2. It offers an alternative to replace a low resolution thumbnail with a higher resolution image. I realized that on Medium, the image linked has to be the full-sized image, which is simply magnified when the user clicks on it. In Fluidbox, the script overlays a ghost element over the image, and loads the image specified in the anchor’s href attribute. This cuts down on bandwidth usage.

Changelog

  • v1.0 — Fluidbox is released
  • v1.1 — Improved performance and debugged Fluidbox, so that image positioning is not messed up when Fluidbox is opened and when the viewport is resized. Partial solution published (see v1.2).
  • v1.2 — Reorganized, rewrote and clarified some of the code. This version fixes the bug of incorrectly positioned Fluidbox upon the resize event being fired. Handles larger image preloading gracefully.
  • v1.3 — Fixed a rounding issue with offset and scaling calculations, and used parseInt() to avoid floating point calculation errors in JS.

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 is the element that we overlay over the entire page when Fluidbox is active, so we obscure the rest of the page’s content. You can choose to use an entire opaque overlay with any colour you want, but for aesthetic purposes I have chosen to overlay it with white with an opacity of 0.85.
  • .fluidbox-wrap is a general wrapper element that is the glue, such that it will hold both the <img> and its ghost element (see next point) together.
  • .fluidbox-ghost is the element that is created by jQuery to mimic the <img> element within the anchor, for the purpose of image replacement with a higher resolution target image whose url is extracted from the anchor’s href attribute.
#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).

Schematics of the calculations.

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

  1. Translate the Flexbox image to the top of the viewport
  2. Offset it by half the difference in height of the scaled image and the original image, since the transform origin is set at the center
  3. Offset it by half the difference in height of the viewport and that of the scaled image, to position the final scaled image in the vertical center of the viewport

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:

  1. Wrap all child content with .fluidbox-wrap
  2. Find the image element, and fade it in by setting opacity to 1
  3. Staying with the image element, we insert a ghost element after it using the .after() method.

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.

  1. Get viewport aspect ratio — we need to compare each image’s aspect ratio to determine if the scaling should be calculated with respect to the image’s width or height. See point #6 for further details.
  2. Fetch image dimensions — needed to calculate image’s aspect ratio, as well as to determine the dimensions of the ghost image element. The width, height and aspect ratio are stored as jQuery data objects for ease of access. This step is only performed when Fluidbox is closed.
  3. Ghost element styling — the ghost element is assigned a dimension equivalent to that of the sibling <img> element. Moreover, we also position it absolutely within the wrapper so we can superimpose it over its sibling. The position of the ghost element is calculated by simply getting it’s offset within the parent element. This is done by comparing the top and left offsets of the wrapper parent and the descendant <img>. This step is only performed when Fluidbox is closed.
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:

  • If the viewport ratio is greater than the image ratio, this means that the viewport is wider than the image itself. Therefore, the height will be the limiting factor, and the scale should be calculated with respect to the image height.
  • Alternatively, if the viewport ratio is smaller than the image ratio, the viewport is narrower than the image. Width becomes the limiting factor, and the scale should be calculated with respect to image width.

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:

  1. Switch the state, by updating the fluidbox-state data value, and changing the class on the wrapper element for styling purposes.
  2. Show the overlay, #fluidbox-overlay.
  3. Set thumbnail image source as the de facto background image, so that we can preload the higher resolution linked image (if any).
  4. Hide the original image
  5. Show the ghost image element, and use the original image source as background image.
  6. Calculate and animate offset and scale by calling the positionFb() function.

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:

  1. Switch the Fluidbox state
  2. Hide the overlay
  3. Show the original image
  4. Reverse the CSS transform
  5. Hide the ghost element, but only after the transitions for CSS transforms are completed
// 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.

  • webkitTransitionEnd for Chrome >1.0 and Safari >3.2
  • MSTransitionEnd for Internet Explorer >10
  • oTransitionEnd for Opera >10
  • otransitionend for Opera 12
  • transitionend for Opera 12.10

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!