So how does it work?
The first thing that comes to mind is to add some kind of “resize” event listener to an element containing some text and a default font family (e.g., font-family: serif;
) applied to it. Then, adding a new font-family with a fallback to the default font-family (e.g., font-family: CustomFont, serif;
) should change the size of the element and fire the “resize” event as soon as the new font is loaded and rendered, assuming of course that the new font has different font metrics.
The good news is that the DOM specification does have the “resize” event. But the bad news is that this event is never fired on HTML elements. It is fired only on the window
object when its size is changed. So running the above example in the browser won’t log the “font loaded” text. The fun part is that IE9 (and maybe other versions of IE) does fire this event when element size changes. But it does so only when binding the event using the non-standard attachEvent
method with the “on” prefix and only when adding the new font-family after 0 timeout. Go figure:
console output:
In any case, the "resize" event can't help us. So we need to find another DOM event and utilize it to mimic the “resize” event behavior. Great! But what is this event?
Looking at the event handlers available for DOM elements at first glance may seem that there are no events that could help us solve our problem. However, there is one event that can help us, and it is the scroll event. According to W3C specification:
“A user agent must dispatch this event when a document view or an element has been scrolled.”
Generally, we have two explicit ways to scroll an element and trigger the “scroll” event. The first way is by using user inputs like mouse or keyboard (e.g., dragging scrollbars, scrolling the mouse wheel, pressing up and down keys, etc.). And the second way is by changing the scrollTop
property of an element using JavaScript. But there is also a third - implicit - way to scroll an element, and it is by changing the size of the element or the size of its contents while satisfying a special initial condition. Thus, causing the “scroll” event to be dispatched when the element's size changes.
Before playing with events, let’s set up the test environment for our experiments. First, we will create an element called “wrapper” having a height of 100 pixels. Then, we will put another element called “content” having a high of 150 pixels into the "wrapper" element. Right now, we are not using any text or fonts. Our goal is to cause the “scroll” event to be dispatched on the “wrapper” element when the size of the “content” element is changed. We will apply overflow: hidden;
on the “wrapper” element to clip its contents and hide the scrollbars to disable scrolling it by user inputs while allowing to scroll it using JavaScript. Then we will change the height of the “content” element using JavaScript.
Running the above code will output the following lines in console:
This example shows that simply changing the height of the “content” element will not change the scrollTop
property of the “wrapper” element. It was expected because changing the height of the "scrollable" element expands its bottom edge downwards or contracts it upwards while its top edge remains at the same place. But it is the scroll position of the element's top edge reflected by the scrollTop
property. And if the scrollTop
property was not changed, the “scroll” event could not have fired.
Ok, so we did not succeed with our first try. But no worries, we are engineers, and engineers aren’t giving up that easy! This time, let’s add the special initial condition that I was talking about. Before setting a new height on the “content” element, we will scroll the “wrapper” element to the bottom. We will set a new value for the scrollTop property to achieve this. The new scrollTop
value can be calculated by subtracting clientHeight
from scrollHeight
: 150 – 100 = 50. Then we will set a new height on the “content” element and see what happens.
console output:
Wow! Did you see that? The scrollTop
property has been changed. But not only that, if we had added the “scroll” event listener to the “wrapper” element before changing the content’s height, the event would have fired right after setting the new height:
console output:
This is Magic!
What happens is that decreasing the "content" element's height while it is scrolled to the bottom pulls the content down to keep its bottom edge glued to the bottom of the "wrapper", forcing its scrollTop
property to change and file the “scroll” event.
To explain this visually, I have prepared a live demo. The demo has a 3D view of the scrollable “wrapper” element containing the “content” element, shown underneath. To indicate when the “wrapper” receives “scroll” events, I have added an event listener for the “scroll” event that flashes the “scrolled” label for half a second. For the demo purpose, the wrapper’s overflow: hidden;
was changed to overflow: auto;
to enable scrolling using GUI. In addition, two buttons have been added besides the bottom left corner of the “content” element to increase and decrease its height by ±40 pixels. This demo shows that increasing or decreasing content height while it is scrolled to the top just changes its height but doesn’t change the scrollTop
property nor fire the “scroll” event. But decreasing content height after scrolling it to the bottom will change the scrollTop
property and fire the scroll event.
How cool is that? Yet, this is only half of the way. If you have played with the demo, you have probably noticed that the “scroll” event is fired only when the content's height is decreased. When its height is increased, the content expands downward without changing the scrollTop
property. In other words, the “scroll” event isn’t fired when content height is increased. However, to load fonts properly, we need the "scroll" event to be fired when content is increased or decreased. This is because the loading font can be smaller or greater than the default "serif" font.
To make the “scroll” event to be fired when the content's height is increased, we need to apply the reverse logic. Instead of changing the height of the “content” element, we need to change the height of the “wrapper” element while the height of the content element remains the same. This way, when the height of the “wrapper” element is increased, its content will be pulled down, forcing its scrollTop
property to change and fire the “scroll” event.
This logic can be implemented by adding an absolutely positioned “innerWrapper” element inside the “content” element and telling it to “track” the content size by specifying 100% for its width and height properties. Then, adding another content element inside the “innerWrapper” element having a greater constant height will lead to the desired result. When the height of the outer “content” element increases, the height of the “innerWrapper” will also increase. However, the height of the inner content will remain the same because it is constant.
console output:
demo:
By merging both methods, we can now create the final HTML structure and JavaScript code to watch for size changes in both directions and dimensions by listening to “scroll” events. Unlike previous examples, the following example calculates and applies all the initial dimensions dynamically based on the dimension of an element with some predefined text and a default “serif” font family. In addition, the difference of ±50 pixels used in previous examples was replaced by ±1 pixel as this difference is enough to catch any size change larger than 1 pixel. And finally, instead of changing content size manually the code adds a new font family to the watched element.
<!DOCTYPE html> | |
<html> | |
<head> | |
<link href="http://fonts.googleapis.com/css?family=Skranji" rel="stylesheet" type="text/css"/> | |
</head> | |
<body> | |
<div id="wrapper" style="position:absolute; overflow:hidden;"> | |
<div id="content" style="position:relative; white-space: nowrap; font-family: serif;"> | |
<div id="innerWrapper" style="position:absolute; width:100%; height:100%; overflow:hidden;"> | |
<div id="innerContent"></div> | |
</div> | |
some text whose size may change | |
</div> | |
</div> | |
<script type="text/javascript"> | |
var wrapper = document.getElementById("wrapper"), | |
content = document.getElementById("content"), | |
innerWrapper = document.getElementById("innerWrapper"), | |
innerContent = document.getElementById("innerContent"), | |
origSize = { | |
width: content.offsetWidth, | |
height: content.offsetHeight | |
}; | |
console.log("original content size: " + origSize.width + "x" + origSize.height); | |
// Resize wrapper and scroll its content to the bottom right corner | |
wrapper.style.width = (origSize.width - 1) + "px"; | |
wrapper.style.height = (origSize.height - 1) + "px"; | |
wrapper.scrollLeft = wrapper.scrollWidth - wrapper.clientWidth; | |
wrapper.scrollTop = wrapper.scrollHeight - wrapper.clientHeight; | |
// Resize inner content and scroll inner wrapper to the bottom right corner | |
innerContent.style.width = (origSize.width + 1) + "px"; | |
innerContent.style.height = (origSize.height + 1) + "px"; | |
innerWrapper.scrollLeft = innerWrapper.scrollWidth - innerWrapper.clientWidth; | |
innerWrapper.scrollTop = innerWrapper.scrollHeight - innerWrapper.clientHeight; | |
wrapper.addEventListener("scroll", function () { | |
console.log("wrapper scrolled, content size decreased: " + content.offsetWidth + "x" + content.offsetHeight); | |
}, false); | |
innerWrapper.addEventListener("scroll", function () { | |
console.log("innerWrapper scrolled, content size increased: " + content.offsetWidth + "x" + content.offsetHeight); | |
}, false); | |
console.log("setting new font family"); | |
content.style.fontFamily = "Skranji, serif"; | |
</script> | |
</body> | |
</html> |
console output on Chrome:
Notes:
scrollTop
or scrollLeft
properties will trigger the “scroll” event immediately. So just like with the previous case, comparing new and initial element sizes will solve this issue.As a convenient way to detect loaded fonts I have created “FontLoader” JavaScript class which is available for download on Github:
Related resources: