Cross-Browser, Event-based, Element Resize Detection

UPDATE: IE as of version 11 no longer supports the native resize event discussed in this article, as such, the code has been architected to use the scroll event, which continues to ensure broad cross browser operation.

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 italicized section is no longer relevant, IE removed the resize event beginning in IE11:

Before we start adding bits to our hacker’s cauldron, let’s review which browsers this will target…you might have just thought to yourself “well, all of them, right?” – but you’d be wrong. This hack is only needed for WebKit and Firefox. In a twist of fate, IE offers built-in support for resize events on regular DOM elements – I shit you not, see for yourself: http://msdn.microsoft.com/en-us/library/ie/ms536959%28v=vs.85%29.aspx.

The following italicized section is no longer relevant, the code now uses the scroll event:

Now, for the DOM alchemy! We’ll need to add to our cauldron overflow or underflow events. If you haven’t heard of them, that’s OK, because I have just the post to get you up to speed – go ahead, I’ll wait here –> Back Alley Coder: Overflow and Underflow Events.

Whew, you’re back! Now that you’re in the overflow know, you might think this hack is simply setting overflow and underflow on an element, but that doesn’t provide us with the functionality we’re after. Overflow and underflow events only fire when an element changes flow state, not each time an element changes size.

We’re going to need a few DOM elements and a couple well-placed scroll event listeners to create what I refer to as ‘sensors’. Let’s take a look at the code you’ll need to make cross-browser element resize events possible:

Resize Sensor HTML

The following HTML block is auto-appended to any element you attach a resize event to. You can only attach resize events to elements that allow children – basically, no elements declared with self-closing tags.

<div class="resize-triggers">
    <div class="expand-trigger"><div></div></div>
    <div class="contract-trigger"></div>
</div>

Resize Sensor CSS

.resize-triggers {
	visibility: hidden;
}

.resize-triggers, .resize-triggers > div, .contract-trigger:before {
  content: " ";
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 100%;
  overflow: hidden;
}

.resize-triggers > div {
  background: #eee;
  overflow: auto;
}

.contract-trigger:before {
  width: 200%;
  height: 200%;
}

Resize Event Methods

The following is the JavaScript you’ll need to enable resize event listening. The first two functions are prerequisites that are used in the main addResizeListener and removeResizeListener methods.

(function(){
	var attachEvent = document.attachEvent;
	
	if (!attachEvent) {
		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 resetTriggers(element){
			var triggers = element.__resizeTriggers__,
				expand = triggers.firstElementChild,
				contract = triggers.lastElementChild,
				expandChild = expand.firstElementChild;
			contract.scrollLeft = contract.scrollWidth;
			contract.scrollTop = contract.scrollHeight;
			expandChild.style.width = expand.offsetWidth + 1 + 'px';
			expandChild.style.height = expand.offsetHeight + 1 + 'px';
			expand.scrollLeft = expand.scrollWidth;
			expand.scrollTop = expand.scrollHeight;
		};

		function checkTriggers(element){
		  return element.offsetWidth != element.__resizeLast__.width ||
				 element.offsetHeight != element.__resizeLast__.height;
		}
		
		function scrollListener(e){
			var element = this;
			resetTriggers(this);
			if (this.__resizeRAF__) cancelFrame(this.__resizeRAF__);
			this.__resizeRAF__ = requestFrame(function(){
				if (checkTriggers(element)) {
					element.__resizeLast__.width = element.offsetWidth;
					element.__resizeLast__.height = element.offsetHeight;
					element.__resizeListeners__.forEach(function(fn){
						fn.call(element, e);
					});
				}
			});
		};
	}
	
	window.addResizeListener = function(element, fn){
		if (attachEvent) element.attachEvent('onresize', fn);
		else {
			if (!element.__resizeTriggers__) {
				if (getComputedStyle(element).position == 'static') element.style.position = 'relative';
				element.__resizeLast__ = {};
				element.__resizeListeners__ = [];
				(element.__resizeTriggers__ = document.createElement('div')).className = 'resize-triggers';
				element.__resizeTriggers__.innerHTML = '<div class="expand-trigger"><div></div></div>' +
													   '<div class="contract-trigger"></div>';
				element.appendChild(element.__resizeTriggers__);
				resetTriggers(element);
				element.addEventListener('scroll', scrollListener, true);
			}
			element.__resizeListeners__.push(fn);
		}
	};
	
	window.removeResizeListener = function(element, fn){
		if (attachEvent) element.detachEvent('onresize', fn);
		else {
			element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1);
			if (!element.__resizeListeners__.length) {
				element.removeEventListener('scroll', scrollListener);
				element.__resizeTriggers__ = !element.removeChild(element.__resizeTriggers__);
			}
		}
	}
	
})();

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:

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!
25 comments
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

@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.

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!