Layout breakpoint (JavaScript)

CSS media-queries are great for responsive layouts. To serve optimised media, though, I still prefer to rely on JavaScript. Not only for the sake of campatibility.

Both CSS media-queries and the HTML srcset attribute can be used in order to tell the browser which assets to download and when, though those are still rather limited.

With a fairly simple JavaScript we can create a custom, powerful media-queries that will save us on bandwidth and speed-up the load time.

The code

The below example shows the HTML code for a single imgset – both <img> tag and also background-image attribute.

<!-- IMG tag -->
<figure class="imgset">
	<img class="imgset-el ar-hero-img preview hide" src="example-122x69.jpg" height="69" width="122" alt="">
	<img class="imgset-el ar-hero-img jsbp def def-x2" data-img-src="example-900x506.jpg" height="506" width="900" alt="">
	<img src="example-1800x1013.jpg" class="imgset-el ar-hero-img jsbp mini-x2 small loaded bp-current" data-img-src="example-1800x1013.jpg" height="1013" width="1800" alt="">
	<img class="imgset-el ar-hero-img jsbp small-x2 xlarge" data-img-src="example-2880x1620.jpg" height="1620" width="2880" alt="">
</figure>

<!-- background-image -->
<figure class="imgset">
    <div class="preview hide" style="background-image:url(example-122x122.jpg)"></div>
    <div class="jsbp def def-x2" data-img-src="example-900x900.jpg"></div>
    <div class="jsbp mini-x2 small loaded bp-current" data-img-src="example-1800x1800.jpg" style="background-image: url('example-1800x1800.jpg');"></div>
    <div class="jsbp small-x2 xlarge" data-img-src="example-2880x2880.jpg"></div>
    <div class="jsbp xlarge-x2 bp4k" data-img-src="example-4000x4000.jpg"></div>
</figure>

The HTML classes of each of the imgset elements represent the breakpoint names. The JS script traverses all elements from the smallest to the largest and tell the browser to download the first element that meets the current display and/or container setup.

As in the shown code snippet, only 4 images, 5 if you count the low-resolution preview, are really needed to serve all common screen sizes. No need for a typical approach with doubling or tripling the asses in order to satisfy all pixel-densities.

To achieve best results all elements of the imgset should be of the same proportions.

##/
#
#  Responsive imageset / sourcest, js powered
#
#  main difference vs CSS media queries
#     * ignores window resize down when large images have been already downloaded
#     * can use any html container as a reference rather than just the viewport, yay!
#
#  Please note!
#  The image loader relies on browser cache so might produce strange effect when testing with cache disabled.
#  
#
# *** *** *** *** *** ***
# SETUP & DEPENDENCIES
#

breakpoints= @import '../../settings/breakpoints.json'
px_density_suffix= '' # default fallback 1x


# *** *** ***
# SUBROUTINES
#

##
#  pix density detection in different os/browsers
#
#  ___checking only up to x2 at the moment, can be increased if needed
#
#
isHighDensity = ->
   window.matchMedia and (window.matchMedia('only screen and (min-resolution: 124dpi), only screen and (min-resolution: 1.3dppx), only screen and (min-resolution: 48.8dpcm)').matches or window.matchMedia('only screen and (-webkit-min-device-pixel-ratio: 1.3), only screen and (-o-min-device-pixel-ratio: 2.6/2), only screen and (min--moz-device-pixel-ratio: 1.3), only screen and (min-device-pixel-ratio: 1.3)').matches) or window.devicePixelRatio and window.devicePixelRatio > 1.3

isRetina = ->
   (window.matchMedia and (window.matchMedia('only screen and (min-resolution: 192dpi), only screen and (min-resolution: 2dppx), only screen and (min-resolution: 75.6dpcm)').matches or window.matchMedia('only screen and (-webkit-min-device-pixel-ratio: 2), only screen and (-o-min-device-pixel-ratio: 2/1), only screen and (min--moz-device-pixel-ratio: 2), only screen and (min-device-pixel-ratio: 2)').matches) or window.devicePixelRatio and window.devicePixelRatio >= 2) and /(iPad|iPhone|iPod)/g.test(navigator.userAgent)

##
#  # # #
#
getCurrentPxDensitySuffix= () ->
   if isHighDensity() or isRetina()
      return '-x2'
   else
      return ''
   
##/
#
#  'Is the element's size good for current breakpoint or what...?'
#
#  Check if the imgset container is in within the scope of breakpoints (can check for multiple bps simultaneously)
#  args (bp names) can be passed as an array or a splat
#  default is lower limit (min) unless 'max' keyword is added such as e. g. max-small
#  the pixel density and orientation can also be added such as e. g. max-xlarge-vert-x2 (the order of components doesn't matter)
#  container element must be passed as first argument, otherwise the $(window) is used
#

el_is_in_bp= (args...) ->
   
   return if not args[0]
   
   # args can be also passed as array
   args= args[0] if args[0].constructor == Array
   
   # can pass the container element, otherwise using window
   if args[0] instanceof jQuery 
      $container= args[0]
      args.shift()
   else 
      $container= $(window)
      
   retval= true
   
   $.each args, (ix, arg) ->

      # split argument to components or just convert to a single elem array
      if arg.indexOf('-') > -1 then arg= arg.split '-' else arg= [arg]
      
      # find the breakpoint name in arg's components (arrays intersect)
      _bp= Object.keys(breakpoints).filter (n) -> 
         arg.indexOf(n) > -1
      
      # no bp means the default image (always true)
      if 0==_bp.length or 'default'==_bp then return true 
      
      # return false after first condition met (negative testing)
      #
      #  pix density
      # max 1x -- (not really used much :)
      if arg.indexOf('x1') > -1 or arg.indexOf('1x') > -1
         if isRetina() or isHighDensity()
            retval= false 
            return false # break
      # min x2
      if arg.indexOf('x2') > -1 or arg.indexOf('2x') > -1
         if not isRetina() and not isHighDensity()
            retval= false 
            return false # break

      # size
      #
      # upper limit
      if arg.indexOf('max') > -1
         
         if arg.indexOf('vert') > -1 or arg.indexOf('vertical') > -1
            if $container.height() >= breakpoints[_bp]
               retval= false 
               return false # break
         else 
            if $container.width() >= breakpoints[_bp]
               retval= false 
               return false # break
      # lower limit
      else 
      
         if arg.indexOf('vert') > -1 or arg.indexOf('vertical') > -1
            if $container.height() < breakpoints[_bp]
               retval= false 
               return false # break
         else 
            if $container.width() < breakpoints[_bp]
               retval= false 
               return false # break
   
   # returns true if none of condition are met                       
   return retval
   

##/
#
#
#  Loads images to browser cache and once downloaded updates background-image src attribute 
#
#  #  Note!
#  The image loader relies on browser cache so might produce strange effect when testing with cache disabled.
#  ___won't work correctly if the browser's cache is turned off, will load the images twice :-(
#  
#  @param $elms_queue -- array, a list of html elements to process
#  the src needs to be specified in the html element's attribute 'data-bgd-src'
#
#

loadImgsetsImgs= ($elms_queue) ->

   return if 0==$elms_queue.length
   
   # recoursive function to
   # load one img at a time for faster response
   
   _process_q= ($elms_queue) ->
      _$el= $elms_queue.shift()
      
      return if _$el.hasClass('.loading')
      
      if _$el.hasClass('.loaded')
         _$el.siblings('.bp-current').removeClass 'bp-current'
         _$el.addClass 'bp-current'
         return
         
      _loader_img= new Image      
      _loader_img.src= _$el.attr 'data-img-src'
      

      if false == _loader_img.complete       # cannot load image
         
         # debug missing images
         # console.log 'Error loading img '+_$el.parent().attr('class')+', size:'+_$el.attr('class')
 
      else   
 
          _$el.addClass 'loading'
                       
         $(_loader_img).on 'load', ->
            
            ##
            # set either src attribute or the background-image depending on setup
            #
            if _$el.prop('tagName') == 'IMG'
               _$el.attr 'src', @src
            else 
               _$el.css 'background-image', 'url('+@src+')'
                     
            _$el.removeClass 'loading'
            _$el.addClass 'loaded'
            
            _$el.siblings('.bp-current').addClass 'hiding'
            _$el.siblings('.bp-current').removeClass 'bp-current'
            _$el.addClass 'bp-current'
            
            
            _$el.siblings('.preview').addClass('hiding') if not _$el.siblings('.preview').hasClass 'hide'
            
            setTimeout () -> 
               _$el.siblings('.hiding').removeClass 'hiding'
            , 1000
   
            _$el.siblings('.preview').addClass 'hide'
   
            return # end on-load
      
      # prevent memory leak
      delete $(_loader_img)
      #_loader_img.remove() # that breaks IE
      
      # recurse if items in queue
      _process_q $elms_queue if $elms_queue.length>0
      return # end process_queue
      
   _process_q $elms_queue
   return # end loadImgsetsImgs
         
##/
#
#
#  Load and show the image of the imgset according to current container or viewport size/px density
#
#
#
#  By default it doesn't load smaller image if the viewport size is decreased
#  This can be overridden by adding data attribute to the imgset html selector: 
#  ====|  data-bp-always-reload=true  |====
#
#
refreshImgsets= () -> 
   
   # create reversed lists of breakpoint keys for looping through
   _bps_keys_rev        = Object.keys(breakpoints).reverse()
   _$els_to_load        = []
   
   $('.imgset').each ->
      _$imgset          = $(@)
      # console.log _$imgset.attr('class')
      
      # an option that enables loading smaller images on resize down (as in classic CSS media queries)
      always_reload     = false
      always_reload     = true if _$imgset.attr('data-bp-always-reload') and 1==_$imgset.attr('data-bp-always-reload')
      
      _$load            = undefined
      _bp_loaded        = undefined
      _bp_needed        = undefined
      _load_is_a_match  = false
      _$match           = undefined
      
      # find breakpoint images already loaded (from largest to smallest)
      $.each _bps_keys_rev, (ix, val) -> 
         $el= _$imgset.find('.jsbp.loaded.'+val+px_density_suffix)
         if $el.length > 0
            _$load      = $el
            _bp_loaded  = val+px_density_suffix
            return false # break 1

      ##
      # use the first (largest) found one for now
      if _$load
         _$imgset.find('.bp-current').removeClass 'bp-current'
         _$load.addClass 'bp-current'
                              
      ##
      # check if we need and can find a better one

      # find first element in imgset that matches the current bp (again, from largst to smallest)
      $.each _bps_keys_rev, (ix, val) ->
         _$imgset.find('.jsbp').each ->
         
            # check if loaded one matches and set a flag
            if val+px_density_suffix==_bp_loaded
               _load_is_a_match= true
            
            # check if element's css classes contain bp of current iteration and if it's the current device's bp
            if $(@).attr('class').split(' ').indexOf(val+px_density_suffix) > -1 and el_is_in_bp(_$imgset, val+px_density_suffix)
               
               if always_reload or not _load_is_a_match
                  _bp_needed= val+px_density_suffix
               
               _$match= $(@)
               
               return false # break 1
         return false if _$match # break 1
      
      # get the first element (smallest bp possible) if different than the loaded one
      if _bp_needed
         _$els_to_load.push _$match 

   
   loadImgsetsImgs _$els_to_load
   
   return
   

# *** *** ***
# RUNTIME
#

px_density_suffix= getCurrentPxDensitySuffix()
refreshImgsets()


# *** *** ***
# utils timeout events

imgsets_window_resize_tout = undefined
$(window).resize ->
   if imgsets_window_resize_tout
      window.clearTimeout imgsets_window_resize_tout
   imgsets_window_resize_tout = window.setTimeout((->
      # actual callback
      px_density_suffix= getCurrentPxDensitySuffix()
      refreshImgsets()
      return
   ), 500)
   return
   
   

Custom breakpoint definitions used by the JSBP scripts.

{
   "def"       : 0,
   "micro"     : 410,
   "mini"      : 481,
   "xxsmall"   : 600,
   "xsmall"    : 736,
   "small"     : 768,
   "medium"    : 961,
   "large"     : 1041,
   "mlarge"    : 1281,
   "xlarge"    : 1441,
   "xxlarge"   : 1881,
   "bp4k"      : 2880
}

Pros of the JS-powered breakpoints:

Cons of the JS-powered breakpoints:

* In an unlike event, when browser cache has been disabled, images will download twice. The browser caching could be removed from the script, though.

Summary

JavaScript is great ;-)

Add a comment