Layered Fonts with CSS and JavaScript

The established ways of using layered fonts on the Web all have problems, so I tried to come up with something new.

Layered fonts are typefaces designed to be overprinted, so that you get a colour effect. Here's an example from Tom Chalky’s Rivina font family:





If you look closely at the word “Chapter” you can see the letters are blue with a greyish outline. I've magnified some of it in the inset circle. The way this works is that there are two separate fonts: TC Outline just draws the outer edges of the font and TC Fill draws the inside of the letters; the text is repeated at the same location, once in each font, to create the effect.

More complex fonts can include many more “layers” than the two I’ve used here (and in fact Rivina includes more variants than I’ve shown).

This technique was invented in the 19th century and has been used in every medium to make a simple coloured printing of headlines.

To use fonts like this on the Web and not be ashamed of ourselves we need: ...


  1. The text must be readable if CSS isn't used, or Web fonts aren't loaded;
  2. The text must appear exactly once in the input document, so that a screen reader doesn't read it out once for each layer, or, worse, doesn't see it at all;
  3. It must be possible to include markup (superscripts, italics, mathematics) in the text;
  4. It must work if JavaScript is turned off, or at least fall back to something readable;
  5. The text must be selectable;
  6. Internationalization means the text's language and writing direction must be honoured.
  7. It must work on mobile devices as well as desktops, laptops, billboards...
The solution I’m about to present meets most of these; it doesn't honour writing direction but could fairly easily do so. If someone sends me (say) a Hebrew layered font I’ll make it work. It also doesn’t work with vertical text (e.g. Chinese); again, I don’t have a suitable font to try it, but the basic technique would work.

The high-level summary is this:
  1. Use a plain HTML heading element such as h1 in the document. That way, if there’s no CSS or javaScript of Web Fonts the result will be readable, including in a text reader.
  2. In CSS, define separate font-face rules for the individual fonts to make up the layers, and assign those to HTML classes such as layer1, layer2 and so on.
  3. In JavaScript, after the heading has loaded, create a wrapper element that contains the original h1 (you can use a pre-existing element such as a section or div if there is one; all that matters is that the h1 is the first element child so you can set position: relative in CSS for it to establish a new stacking context...
  4. Duplicate the h1 (or whatever) once for each layer, in JavaScript, and assign the copies class values layer1, layer2, and so on (with layer1 going to the original h1). Also add the ARIA role aria-hidden="true" so that screen readers won't read the element. (Add the ARIA role before inserting the elements into the document, of course).
  5. Find the position of the original h1 element and position the new elements in the same place, taking scrolling into account.
    Here’s a picture of another example, this time using a free font, Agreloy:
    This one has a shadow layer, so that should go before the original h1, so that if the user has CSS enabled but no JavaScript, they get the base layer of the font and can still read the text. You have to experiment to find which layer is the most readable.

    You can see this example live on the Web here. I have not packaged the JavaScript in a library because so far I've had to mess with it to choose the order of the layers, but probably I could make it read a "data-font-layers" attribute or something.

    I also found some fonts (including Agreloy as it happens) don’t always render well in Web browsers; you may also need to rename the font in your font-face rule because CSS doesn’t understand fonts with lots of variants other than bold and italic. But it does work.

    So, in the CSS, we first define the fonts we will use:
    @font-face {
      font-family: 'Agreloy-base';
      src:  url('Agreloy.otf');
    }

    @font-face {
      font-family: 'AgreloyInT3';
      src:  url('AgreloyInT3.otf');
    }

    @font-face {
      font-family: 'AgreloyOut1';
      src:  url('AgreloyOut1.otf');
    }

    @font-face {
      font-family: 'AgreloyInB1';
      src:  url('AgreloyInB1.otf');
    }
    (in production, you should probably use one of the "bullet-proof font loading" schemes)

    In my example i have a div element with class=border that has the h1 element as its first child, so that's what I'll use:
    div.border {
        position: relative;
    }
    This makes the base for measuring the position of the element to put the duplicates in place. You can go to the live Web demo mentioned above and disable this style rule in the element inspector to see the difference it makes.

    Then we define styles for the base h1, including the fallback. Since Agreloy can be read (by sighted users) if it is the only font that is used, I’ve used that for the h1, to give as similar an appearance as possible:
    h1 {
        text-align: left;
        font-size: 96pt;
        line-height: 120pt;
        font-family: "Agreloy-base";
        color: #7e534d;
    }

    Now we define the colours for individual layers. Note that in my example the background layer draws a drop shadow, and because the Web browser doesn't seem to render it properly I've set opacity to 0.2:
    h1#layer0 {
        /* the shadow laer renders a pixel to large in FF and Chrome on Linux,
         * so we'll give it opacity 0.2 so it makes a simple drop shadow.
         * */
        font-family: "AgreloyOut1";
        color: #000;
        position: absolute;
        opacity: 0.2;
    }
    Now for the other two layers:
    h1#layer2 {
        font-family: "AgreloyInT3";
        color: #73934d;
        position: absolute;
    }
    h1#layer3 {
        font-family: "AgreloyInB1";
        color: #240000;
        position: absolute;
    }

    OK, let's put these in position with JavaScript. Remember, the input just has a div with a single h1 and then p elements with text in them.

    var h1 = document.querySelector('h1'); // could use a class, h1.layeredfont
    var parent = h1.parentElement;
    var h0 = h1.cloneNode(true); // underneath
    var h2 = h1.cloneNode(true); // true = include children
    // var h3 = h1.cloneNode(true); // true = include children

    h0.setAttribute("id", "layer0"); // join the h1 to the CSS via classes
    h2.setAttribute("id", "layer2");
    // h3.setAttribute("id", "layer3");

    h2.setAttribute("aria-hidden", "true"); // so screen readers don't speak it
    // h3.setAttribute("aria-hidden", "true");

    /* position the new h2 element on top of the old one, taking
     * the current ducment scroll into account:
     * */
    var scrollTop = document.documentElement.scrollTop?
                document.documentElement.scrollTop : document.body.scrollTop;
    h0.style.top = h2.style.top =
        (0 - h1.getBoundingClientRect().y - scrollTop) + "px";

    var scrollLeft = document.documentElement.scrollLeft?
                 document.documentElement.scrollLeft : document.body.scrollLeft;
    h0.style.left = h2.style.left = (0 - scrollLeft) + "px";

    h1.before(h0);
    h1.after(h2); /* IE needs polyfill for .ater */
    /* see https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/after */
    // h2.after(h3); /* a third layer might look like this */

    Arrange for the JavaScript to be loaded after the document, for example by putting this just before the close tag for the body element:
    <script src="layeredfont.js" type="text/JavaScript"></script>

    The only problem i've seen so far is that on some Android devices the user might need to reload after switching between portrait and landscape modes; if you can repproduce this and have a fix, let me know in the comments. As well as any other problems, of course.

    Comments

    Unknown said…
    Very cool. I particularly agree with your focus on maintaining accessibility :-)
    Liam Quin said…
    Thanks! (why didn't my reply in October make it through?)

    Popular posts from this blog

    GEGL Plug-Ins for GIMP. Part One: Using GEGL Plug-Ins

    Happy Birthday, World Wide Web

    Drop Caps: Other Writing Systems, Other Styles