Web font loading detection, without timers

posted on March 23, 2013

After writing my first post about detecting when particular font families have been loaded and rendered on a web page, I kept thinking about the efficiency of this approach. The fact that the time taken to download and render fonts on a web page depends on many factors, like the network latency and the processing power of the client device, makes it difficult to choose the right time interval for sampling the dimensions of elements. A long interval might introduce extra waiting time if the font was loaded just after a new timer iteration has been started. On the other hand, a short interval may introduce processing time overhead when trying to get the dimensions of elements by obtaining layout triggering properties like offsetWidth and offsetHeight. So I kept thinking about how can I force the browser to notify me when a custom font has been loaded without sampling the dimensions of the element containing the loading font. And I found an event that can be used to solve this problem!

If you just want to use the FontLoader without reading this post navigate to the Github repo and follow the instructions in readme file:

Before I dive into the technical details of the solution, I want to show two event timelines showing the difference between the events fired in the web page when using Typekit's Web Font Loader that uses timeouts and the events fired when using a method that does not use timeouts, as explained in this post. These event timelines are taken from the chrome developer tools.

typekit events timeline (with timeouts)
typekit events timeline (with timeouts)
fontloader event timeline (without timeouts)
fontloader event timeline (without timeouts)

So how does it work?

Step I: Searching for an event.

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.

<div id="tester" style="position:absolute; font-family:serif;">Hello World!</div>
<script>
    var element = document.getElementById("tester");
 
    element.addEventListener("resize", function() {
        console.log("resize fired, font loaded");
    }, false);
 
    element.style.fontFamily = "Georgia, serif";
</script>

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:

<div id="tester" style="position:absolute; font-family:serif;">Hello World!</div>
<script>
    var element = document.getElementById("tester");
    if (typeof element.attachEvent === "function") {
        element.attachEvent("onresize", function () {
            console.log("onresize fired, font loaded");
        });
    }
    window.setTimeout(function() {
        console.log("add new font-family");
        element.style.fontFamily = "Georgia, serif";
    }, 0);
</script>

console output:

add new font-family 
onresize fired, font loaded

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.

Step II: Setting up the environment

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.

<div id="wrapper" style="width:100px; height:100px; overflow:hidden;">
    <div id="content" style="height:150px;"></div>
</div>
<script type="text/javascript">
    var wrapper = document.getElementById("wrapper"),
        content = document.getElementById("content");
 
    console.log("initial scrollTop: " + wrapper.scrollTop);
 
    content.style.height = "140px";
    console.log("scrollTop after decreasing content height: " + wrapper.scrollTop);
 
    content.style.height = "160px";
    console.log("scrollTop after increasing content height: " + wrapper.scrollTop);
</script>

Running the above code will output the following lines in console:

initial scrollTop: 0
scrollTop after decreasing content height: 0
scrollTop after increasing content height: 0

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.

Step III: Triggering the scroll event by decreasing content size

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.

wrapper.scrollTop = wrapper.scrollHeight - wrapper.clientHeight;
console.log("initial scrollTop: " + wrapper.scrollTop);
 
content.style.height = "140px";
console.log("scrollTop after decreasing content height: " + wrapper.scrollTop);

console output:

initial scrollTop: 50
scrollTop after decreasing content height: 40

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:

wrapper.scrollTop = wrapper.scrollHeight - wrapper.clientHeight;
console.log("initial scrollTop: " + wrapper.scrollTop);

wrapper.addEventListener("scroll", function() {
    console.log("wrapper scrolled, scrollTop: " + this.scrollTop);
}, false);

content.style.height = "140px";
console.log("scrollTop after decreasing content height: " + wrapper.scrollTop);

console output:

initial scrollTop: 50
scrollTop after decreasing content height: 40
wrapper scrolled, scrollTop: 40

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.

Note: This “magic” doesn’t work with IE, at least not with IE9.

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.

Step IV: Triggering the scroll event by increasing wrapper size

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.

<div id="content" style="position:relative; width:100px; height:150px;">
    <div id="innerWrapper" style="position:absolute; width:100%; height:100%; overflow:hidden;">
        <div style="height: 200px;"></div>
    </div>
</div>
<script type="text/javascript">
    var innerWrapper = document.getElementById("innerWrapper"),
        content = document.getElementById("content");

    innerWrapper.scrollTop = innerWrapper.scrollHeight - innerWrapper.clientHeight;
    console.log("initial scrollTop: " + innerWrapper.scrollTop);

    innerWrapper.addEventListener("scroll", function() {
        console.log("innerWrapper scrolled, scrollTop: " + this.scrollTop);
    }, false);

    content.style.height = "160px";
    console.log("scrollTop after increasing content height: " + innerWrapper.scrollTop);
</script>

console output:

initial scrollTop: 50
scrollTop after increasing content height: 40
innerWrapper scrolled, scrollTop: 40

demo:

Step V: The final result

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.

Loading gist https://gist.github.com/smnh/10325009

console output on Chrome:

original content size: 213x18
setting new font family
innerWrapper scrolled, content size increased: 248x22
Update: Recently Adobe introduced the Adobe Blank font. Using this font as a fallback font removes the need to watch for the decrease of an element's size because the size of an element with text having this font is 0.

Notes:

  • The “scroll” event handler may be called twice for each wrapper if one size dimension is decreased and another is increased. Therefore new element size should be compared with its initial size to filter out secondary “scroll” events.
  • On some browsers, adding event listeners right after setting 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 usual, this method doesn’t work with IE, at least not with IE9. Therefore for IE, I recommend using the standard method for watching element size – sampling element size with intervals.

As a convenient way to detect loaded fonts I have created “FontLoader” JavaScript class which is available for download on Github:

Related resources: