Sunday, December 15, 2013

Working with Variable Height CSS Floats in Responsive Layouts

The Bootstrap grid system is a great toolkit for constructing fluid, responsive page layouts. Typically, you place enough .col-* elements into a .row parent so the total of the column sizes equals 12. However, nothing prevents you from adding as many columns to a row parent as you'd like. They will simply flow to the next row after reaching the sizing of 12. I was curious if I could use one row parent and layout out a 3 column grid of Bootstrap panels with equal width that would simply flow to the next row every 3 panels. The problem I encountered was while each panel my be equal width, it is not equal height. Using CSS floats to layout the content with variable height elements results in the content not necessarily wrapping to the beginning of the next row. Instead, they could float right below the last element on the previous row or anywhere else depending on the height of the previous elements:



Clearly, a CSS-only solution was not going to work so I started looking at what Javascript options existed. One possibility I investigated was using Masonry, however, it attempts to make elements fit together in a grid. In some situations, this does make sense, however, I really wanted to have something that aligned the tops on each row. Some further searching led me to Isotope's fitRows option which is the look I wanted to achieve. Upon thinking about it through, solving this problem didn't seem to require a dedicated library. Bootstrap already handled shaping the structure I wanted, but the positioning of the layout needed some tweaking. It appeared that if I could find the tallest panel in each row, then I could force all the shorter panels to match which would cause the next row to fall into the proper position. Given that, I came up with this code to measure the heights and match them accordingly:


function fitRows( $container, options ) {

   var cols = options.numColumns,
       $els = $container.children(),
       maxH = 0, j;

   $els.each(function( i, p ) {

      var $p = $( p ), h;

      $p.css( 'min-height', '' );

      maxH = Math.max( $p.outerHeight( true ), maxH );
      if ( i % cols == cols - 1 || i == cols - 1 ) {
         for ( j=cols;j;j--) {
            $p.css( 'min-height', maxH );
            $p = $p.prev();
         }
         maxH = 0;
      }

   });
}

$(function() {

   var opts = {
      numColumns: 3
   };

   fitRows( $( '.tiles' ), opts );

});



The .tiles target referenced above is the container to the content on which I want to fix the heights:


   <div class="row tiles">
      <div class="col-sm-4">
         ...
   


The heights of the .col-* elements will be measured and set based on the max found within each group of numColumns in a "row". With this code in place, the page now renders the panels where I want them:



Now there are still a few problems to resolve with the solution. When your browser window is smaller than the media breakpoint defined for the grid columns you're using, each column expands to fit the width of the container. If the calculated heights are left alone, you're layout will look something like this:



Obviously, once the break point is reached, you don't need to fix the heights any more and those should be removed. However, before that can happen, you need to detect that the breakpoint has been reached. My solution was to measure the child and parent widths do see if they were the same:


   doSize = ( $container.width() != $els.outerWidth(true) );

   ...

   // Inside each() //
   $p.css( 'min-height', '' );
   if ( !doSize ) return;

   ...


With that logic in place, the content will switch to a layout like this:



The only remaining issue to address is when the browser window changes size. The content will resize and reflow automatically, however, the heights need to be recalculated to account for the new width. If the fitRows function is called when a window resize event is fired, the logic can be run to determine whether to calculate the heights or remove them because the media breakpoint was reached. The final code with all the pieces is below:


function fitRows( $container, options ) {

   var cols = options.numColumns,
       $els = $container.children(),
       maxH = 0, j,
       doSize;

   doSize = ( $container.width() != $els.outerWidth(true) );

   $els.each(function( i, p ) {

      var $p = $( p ), h;

      $p.css( 'min-height', '' );
      if ( !doSize ) return;

      maxH = Math.max( $p.outerHeight( true ), maxH );
      if ( i % cols == cols - 1 || i == cols - 1 ) {
         for ( j=cols;j;j--) {
            $p.css( 'min-height', maxH );
            $p = $p.prev();
         }
         maxH = 0;
      }

   });
}

$(function() {

   var opts = {
      numColumns: 3
   };

   fitRows( $( '.tiles' ), opts );

   $( window ).on( 'resize', function() {
      fitRows( $( '.tiles' ), opts );
   });
});



This works well for my specific needs. If I had more containers on a page that needed monitoring, it might make sense to wrap it into a widget. Depending on you're needs, this basic solution may be sufficient or you may want a more robust solution offered by Masonry or Isotope. I prefer to use CSS as much as possible and only switch to Javascript to affect layout when a reasonable solution is not possible with CSS alone.