Cross-Browser, Event-based, Element Resize Detection

UPDATE: This post has seen a significant change from the first version of the code. It now relies on a much simpler method: a hidden object element that relays its resize event to your listeners.

DOM Elements! Y U No Resize Event?

During your coding adventures, you may have run into occasions where you wanted to know when an element in your document changed dimensions – basically the window resize event, but on regular elements. Element size changes can occur for many reasons: modifications to CSS width, height, padding, as a response to changes to a parent element’s size, and many more. Before today, you probably thought this was mere unicorn lore, an impossible feat – well buckle up folks, we’re about to throw down the gauntlet.

Eye of Newt, and Toe of Frog

The following is the script provides two methods that take care of everything. To enable our resize listening magic, we inject an object element into the target element, set a list of special styles to hide it from view, and monitor it for resize – it acts as a trigger for alerting us when the target element parent is resized. The first method the script provides is addResizeListener, it manages all your listeners and monitors the element for resize using the injected object element. The other method is removeResizeListener, and it ensures that your listeners are properly detached when you want them removed.

(function(){
  var attachEvent = document.attachEvent;
  var isIE = navigator.userAgent.match(/Trident/);
  console.log(isIE);
  var requestFrame = (function(){
    var raf = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame ||
        function(fn){ return window.setTimeout(fn, 20); };
    return function(fn){ return raf(fn); };
  })();
  
  var cancelFrame = (function(){
    var cancel = window.cancelAnimationFrame || window.mozCancelAnimationFrame || window.webkitCancelAnimationFrame ||
           window.clearTimeout;
    return function(id){ return cancel(id); };
  })();
  
  function resizeListener(e){
    var win = e.target || e.srcElement;
    if (win.__resizeRAF__) cancelFrame(win.__resizeRAF__);
    win.__resizeRAF__ = requestFrame(function(){
      var trigger = win.__resizeTrigger__;
      trigger.__resizeListeners__.forEach(function(fn){
        fn.call(trigger, e);
      });
    });
  }
  
  function objectLoad(e){
    this.contentDocument.defaultView.__resizeTrigger__ = this.__resizeElement__;
    this.contentDocument.defaultView.addEventListener('resize', resizeListener);
  }
  
  window.addResizeListener = function(element, fn){
    if (!element.__resizeListeners__) {
      element.__resizeListeners__ = [];
      if (attachEvent) {
        element.__resizeTrigger__ = element;
        element.attachEvent('onresize', resizeListener);
      }
      else {
        if (getComputedStyle(element).position == 'static') element.style.position = 'relative';
        var obj = element.__resizeTrigger__ = document.createElement('object'); 
        obj.setAttribute('style', 'display: block; position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden; pointer-events: none; z-index: -1;');
        obj.__resizeElement__ = element;
        obj.onload = objectLoad;
        obj.type = 'text/html';
        if (isIE) element.appendChild(obj);
        obj.data = 'about:blank';
        if (!isIE) element.appendChild(obj);
      }
    }
    element.__resizeListeners__.push(fn);
  };
  
  window.removeResizeListener = function(element, fn){
    element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1);
    if (!element.__resizeListeners__.length) {
      if (attachEvent) element.detachEvent('onresize', resizeListener);
      else {
        element.__resizeTrigger__.contentDocument.defaultView.removeEventListener('resize', resizeListener);
        element.__resizeTrigger__ = !element.removeChild(element.__resizeTrigger__);
      }
    }
  }
})();

Demo-licious!

Here’s a pseudo code usage of the method.

var myElement = document.getElementById('my_element'),
    myResizeFn = function(){
        /* do something on resize */
    };
addResizeListener(myElement, myResizeFn);
removeResizeListener(myElement, myResizeFn);

Cut to the chase, let’s see this resize thang in action: Demo of resize listeners

Resize ALL The Things!

Now that we’re equipped with a nifty, cross-browser element resize event, what would it be good for? Here’s a few possible uses:

  • Resize-proof Web Component UI development
  • Per-element responsive design
  • Size-based loading of content
  • Anything you can imagine!
35 comments
eggers
eggers

The jsfiddle doesn't work for chrome unless I'm missing something.

dsrw
dsrw

Line 60 is trying to remove the resize event from the element rather than the resize trigger. It should read `element.__resizeTrigger__.contentDocument.defaultView.removeEventListener('resize', resizeListener);`

Brandon Kelly
Brandon Kelly

We've been using this technique extensively in Craft's (http://buildwithcraft.com) Control Panel, and it works great.


I would like to recommend one addition to the CSS, which will fix a bug where the resize trigger scrollbars are sometimes visible in Chrome.


  .resize-triggers > div::-webkit-scrollbar {

      display: none; /* Fixes the Chrome scrollbar bug */

    }

tenderloin420
tenderloin420

This is awesome, thank you sir! I'm keen to see the new method you've spoken about in the other comments.

zenorbi
zenorbi

The scroll hack doesn't work if the element that is being watch hides (or any of its parents), because browsers reset the scrollTop and scrollLeft values. Safari, Firefox and Chrome could use the underflow/overflow hack, <IE11 can use the onresize event, but IE11 is left out. You can test this by just setting display:none and setting is back again to default, or by removing the element and adding it back again. Anybody have a solution?

rawbear
rawbear

Could you explain why scroll works here?  I'm not quite connecting the dots.  

staaky
staaky

Thanks for this, it gave me some ideas for alternatives since I couldn't get it to work everywhere reliably.

I had more luck using an iframe, its window has a resize event that can be used. I basically replaced the overflow logic with a dynamically created iframe, written into it is a window.onresize function that checks viewport dimensions and reports back to the parent. I also pass along an ID that helps find the correct callback when reporting back to the parent window.

Works perfectly in Firefox, but unfortunately the resize event triggers way too often in Chrome, even when iframes aren't resizing (I should probably report that bug). So that being buggy drags down performance.

An alternative is using MutationObserver and a fallback to MutationEvents to check dimensions. The problem with those is that changes to layout caused by things like a CSS :hover don't trigger events, so approaches that check the actual size still seem like the best way to go.

Permonix
Permonix

I successfully used event-based resize detection for table cell resize detection in Chrome and Opera, but I have problem with table cell resize detection in Firefox.

Problem is that Firefox ignores "position: relative" in table cell (td, th). It means that it is not posible use cell as offset parent of resize-sensor. I tried create wrapper div inside table cell and use this div as parent of resize-sensor. It works partially, but there is problem with height detection. It is not possible to force wrapper div to grab 100% height of cell (if cell height is not explicitly set in pixels). So this solution is not universally applicable.

If someone knows the ultimate solution of event-based table cell resize detection in Firefox, please tell me. Thanks.

JohnnyHauser
JohnnyHauser

I ran into an interesting scenario. It seems that this requires the element you pass into the add function to contain at least one character or even a list bullet. Without one, the event won't fire. I'd love some insight into this. I "solved" it by appending this this: 

<span style="font-size:0; height:0">a</span>

Obviously a hack, but it works. Here's your fiddle with the text removed from the list and list-style set to none.
http://jsfiddle.net/3QcnQ/48/
You can see that the event no longer triggers on resize. Adding back the list bullets or un-commenting my span (added to the list) will fix it.

All that said, this function is totally awesome. I've been bragging on you to some friends for a few days - this trick is just downright clever. Also, this may very well be the only way of dynamically updating an iframe, which is exactly what I needed.

tbroyer
tbroyer

FYI, GWT uses a similar technique but with scroll events: it creates 2 divs with overflow:scroll and size 100%; within each of them it creates another div. The first is sized a bit more than 100% to account for the scrollbars (computed, in pixels), the second 200%; and their scrollTop and scrollLeft are maximized. Whenever the element's size changes, the inner scrollable elements are also resized. As a result, their inner elements' position will change and scroll events will fire.

 

The source (in Java) with detailed comments about how it all works, can be read at https://code.google.com/p/google-web-toolkit/source/browse/trunk/user/src/com/google/gwt/user/client/ui/ResizeLayoutPanel.java?r=11480 (ImplStandard)

csuwldcat
csuwldcat moderator

@eggers I've updated the jsFiddle link to show the non-iframed 'show/light' version of the demo. Chrome has a bug where elements with onload do not fire when in a nested iframe. Should work in Chrome now if you try it. Hopefully you don't need to use this on an element that is located inside an iframe :/

csuwldcat
csuwldcat moderator

@dsrw 'element' and 'element.__resizeTrigger__' are references to the same element ;)

csuwldcat
csuwldcat moderator

@Brandon Kelly hey Brandon, great to hear this helped you. I've just overhauled the code a lot and changed the approach. It now uses a hidden object element to sniff out the resize occurrences, which is faster, simpler, and less code than the last version. Can you test it for me and let me know if it works well for you? There is now no need to add any external CSS bits, everything is provided by the script.

csuwldcat
csuwldcat moderator

@tenderloin420 I just updated the post with the new method - can you let me know if it works for you?

csuwldcat
csuwldcat moderator

@zenorbi I have actually come up with a new method that is probably 1/3 the code weight, and flawlessly works as far back as IE6! Give me a week to get a new post together.


As for this issue, the display: none; I didn't test for such a case, so I have to do that. I reworked the code to operate correctly in IE11, but the display: none; thing is a separate issue. I'm not going to put too much more time into this version, given I have something that is a silver bullet for the whole resize event topic.

ivan4th
ivan4th

@rawbear there are hidden divs with scrollbars, resizing causes them to change scrollLeft/scrollTop, thus 'scroll' event is fired. What I don't understand is why expand-trigger is necessary, seems like contract-trigger catches everything. Also, it's not quite clear why requestAnimationFrame is used here, maybe sometimes events are skipped during continuous resize? Needs more info from the author or a lot of experimentation / reading specs.

csuwldcat
csuwldcat moderator

@staaky I've completely reworked the code to use scroll events, it seems 100% reliable across even more (and older) browsers.

csuwldcat
csuwldcat moderator

@Permonix I've completely reworked the code to use scroll events, it seems 100% reliable across even more (and older) browsers.

dsrw
dsrw

@csuwldcat It isn't, unless I'm doing something wrong. Element is the element that I'm actually monitoring (a DIV, in my case). element.__resizeTrigger__ is the object tag that gets inserted by addResizeListener.


I get "Cannot read property 'defaultView' of undefined" errors unless I change line 60.

Brandon Kelly
Brandon Kelly

@csuwldcat Hah, bad timing on my part then :)


This new approach works really well. Noticeably faster, and the code is definitely much simpler!  Unfortunately, on a page with heavy use of custom resize events, Chrome is actually crashing on me now. I've spent the majority of the day trying to narrow it down, but browser crashes are much tougher to debug than normal JS errors/exceptions.


I ended up fixing this by updating resizeListener() to stop using requestAnimationFrame(). Now it just checks to make sure the element has actually resized before calling the event handler, by storing its offsetWidth/Height on `win`. You can see my work here: https://github.com/pixelandtonic/Garnish/commit/ac6e2da2c3d207061ed8f55871012e2913792773 (It's modified quite a bit because I'm working it into our UI library's custom addListener() method, but should be easy enough to grasp.)

zenorbi
zenorbi

@csuwldcat @Brandon Kelly The object approach doesn't work for Safari and Chrome (both on a Mac) but if I change the object to and iframe, so the detection is on the iframe emulated window, it works great.

deebeejay
deebeejay

@csuwldcat @zenorbi +1, quite interested in the new approach, too. i'm having trouble getting this one to work at all with IE10 and down. :[

ivan4th
ivan4th

... ok, got it, rAF is used to postpone handler invocation so that it's invoked just one time for multiple consecutive scroll events (e.g. both triggers fired). Still can't understand why expand-trigger is needed though, i.e. can't quite imagine the situation where contract-trigger doesn't fire.

csuwldcat
csuwldcat moderator

@sdecima @gjjones I've completely reworked the code to use scroll events, it seems 100% reliable across even more (and older) browsers.

gjjones
gjjones

@sdecima That's awesome! Thank you for the reply and tip. I really appreciate the heads up :)

ivan4th
ivan4th

Ok, I got thoroughly confused when playing when the code. I've added some console.log() calls to scroll event handlers, and contract handler was always firing because of resetTriggers().


In fact, it works as follows: the monitored element is covered by an invisible (visibility: hidden) div (.resize-triggers) that has two scrollable (overflow: auto) divs of the same size (.expand-trigger and .contract-trigger).

Both .expand-trigger and .resize-trigger have maximum possible values of scrollLeft and scrollTop.

.expand-trigger's content is set to be just one pixel wider and higher than .expand-trigger itself, thus when .expand-trigger's height or width increases just by one pixel, the corresponding scrollbar on .expand-trigger disappears, its scrollTop or scrollLeft becomes 0 causing 'scroll' event to fire. On the other hand, when width or height decreases, scrollTop and scrollLeft of .expand-trigger don't change, thus no events are fired.


.contract-trigger's content is set to be twice as wide and twice as high as .contract-trigger itself, so when width or height decrease, the scrollbar is pushed to the left, decreasing scrollLeft / scrollTop and thus causing 'scroll' event. When width or height increase, some free space appears to the right / below the scrollbar, but scrollLeft / scrollTop don't change, so no scroll events appear.


When 'scroll' event is caught, triggers are reset by adjusting .expand-trigger content size and setting scrollLeft / scrollTop values of the triggers to their maximum possible values. This by itself may cause more scroll events, but this appears not to be causing endless loops (still need to wrap my head around this a bit). Actual resize handling is postponed via requestAnimationFrame() (or setTimeout() when rAF is not available), multiple consecutive scroll events are aggregated into one.

csuwldcat
csuwldcat moderator

@alextat @csuwldcatthe event name in the attacheEvent logic for old IE was wrong, it was attaching 'resize' instead of 'onresize'. I've changed it in the post, should work fine now.

alextat
alextat

@csuwldcat provided jsfiddle example isn't working on ie 10.


looks like problem is in line


if (attachEvent) element.attachEvent('resize', fn);


any ideas how to fix this?

sdecima
sdecima

@csuwldcat Thanks! The new code works great on IE11 (and all other browsers).

csuwldcat
csuwldcat moderator

@vsync @csuwldcat@tbroyerI don't see that issue, and your jsbin example doesn't have any of the code in it - what is that testing? The method now uses a mechanism similar to what @tbroyer alluded to, and I haven't experienced issues. Can you make a jsBin example that actually shows what you are indicating?

mflodin
mflodin

@csuwldcat It isn't changed in the jsFiddle (or it links to an old version), so there it still doesn't work.


Really nice work all in all. I'm really surprised there are no standard events for this. Even the new MutationObserver doesn't observe size changes. So thanks a lot for this!