Beyond CSS Media-Queries: Leveraging JavaScript for Advanced Responsive Design

In today’s digital age, having a website that adapts to different devices is essential. With so many different screen sizes and resolutions, responsive design has become the norm. CSS media-queries are often used to create responsive layouts, but they have some limitations when it comes to serving optimised media. This is where JavaScript comes in handy.

JavaScript can be used to create custom media-queries that can save bandwidth and speed up the load time of your website. While CSS media-queries and the HTML srcset attribute can be used to tell the browser which assets to download and when, JavaScript can provide more advanced functionality.

With a fairly simple JavaScript code, you can create a custom, powerful media-query that checks the size of the image container rather than just the viewport. This can help you serve the appropriate image size for different container sizes.

In addition, JavaScript can check pixel-density as well as the viewport/container size at the same time, unlike the HTML srcset attribute. This means that you can serve the appropriate image size for different pixel densities and container sizes, which can result in faster load times and better user experience.

One of the advantages of using JavaScript for breakpoints is that it allows for seamless transitions when swapping assets. There is no need for a reload on resize-down, which can result in a better user experience. However, creating custom media-queries with JavaScript can be more complicated and sometimes tricky to debug. It also requires additional CSS and some back-end code.

Responsive images in custom WordPress theme

In this chapter, we will delve deeper into the world of JavaScript-powered breakpoint systems. We will explore how to use breakpoint systems to manage responsive images, and discuss best practices for implementation.

To achieve the best results with JavaScript-powered breakpoints, breakpoint values should be defined in a JSON or database, so both back-end and the JavaScript code can use them. In addition, all elements of the imgset should be of the same proportions to ensure that the appropriate image size is served for each container size.

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
}

In summary, while CSS media-queries and the HTML srcset attribute are useful for creating responsive layouts, JavaScript can be used to create custom media-queries that provide more advanced functionality. By using JavaScript to serve optimised media, you can save bandwidth and speed up the load time of your website, resulting in a better user experience.