Browser…​unplugged
Showcases of modern Browser technologies

May 13, 2018

Animated calligraphy

At Stackoverflow, from time to time the questions pops up whether there is an equivalent to the stroke-dashoffset technique  for animating the SVG stroke that works for the fill attribute. Looking closer, what is meant most of the time comes to this:

I have something that is sort of a line, but because it has varying brush widths, in SVG it is defined as the fill of a path. How can this "brush" be animated?

In short: How to animate calligraphy?

A mask path covers the calligraphic brush

The basic technique for this is relatively simple. Draw a second (smooth) path on top of the calligraphy so that it follows the brush line. Choose the stroke width in such a way that it covers the calligraphy everywhere.

This path on top will be used as a mask for the lower one. Apply the stroke-dashoffset animation technique to the mask path. The result will look as if the lower path is "written".

This is a case for a mask, not a clip-path - that would not work. Clip-paths always reference the fill area of a path, but ignore the stroke.

The easiest variant is to set stroke: white for the path in the mask. Then everything outside the area painted white is hidden, and anything inside is shown without alteration.

See the Pen Writing calligraphy: basic example by ccprog (@ccprog) on CodePen.

So far, so simple. Things get tricky when the calligraphic lines overlap. This is what happens in a naive implementation:

See the Pen Writing calligraphy: faulty intersection by ccprog (@ccprog) on CodePen.

The cut on the mask path and the calligraphic brush must match

At the intersection point, the mask reveals part of the crossing brush. Therefore it is neccessary to cut up the calligraphy into non-overlapping pieces. Stack them in drawing order and define separate mask paths for each one.

The most tricky part is to maintain the impression of a continuous stroke. If you cut a smooth path, its stroke ends will fit together as long as both path tangents have the same direction at their common point. The stroke ends are perpendicular to that, and it is essential that the cut in the calligraphic line aligns exactly. Take care all paths have consecutive directions. Animate them one after the other.

While a lot of line animations can go by with rough estimates of the length to use for the stroke-dasharray, here you need real measurements (small roundings shouldn't hurt). So, just as a reminder, you can get them in the dev tools console with

document.querySelector('#mask1 path').getTotalLength()

See the Pen Writing calligraphy: divide up intersections by ccprog (@ccprog) on CodePen.

The "one after the other" part is slightly awkward to write in CSS. The best pattern is probably to give all partial animations the same begin time and total duration and to set intermediate keyframes between which the stroke-dashoffset changes. Like this:

@keyframes brush1 {
      0% { stroke-dashoffset: 160; } /* leave static */
     12% { stroke-dashoffset: 160; } /* start of first brush */
     44% { stroke-dashoffset: 0; }   /* end of first brush equals start of second */
    100% { stroke-dashoffset: 0; }   /* leave static */
}
@keyframes brush2 {
      0% { stroke-dashoffset: 210; } /* leave static */
     44% { stroke-dashoffset: 210; } /* start of second brush equals end of first */
     86% { stroke-dashoffset: 0; }   /* end of second brush */
    100% { stroke-dashoffset: 0; }   /* leave static */
}

Further down, you'll see how a SMIL animation enables a more fluent and expressive way to define times. Keeping with CSS, computations done in SCSS might be pretty helpful.

Left: the mask path, right: its application

A comparable problem appears if the curve radius of the mask path gets smaller than the stroke width. While the animation runs through that curve, it may happen that an intermediate state looks seriously crooked.

The radius stays large enough

The solution is to move the mask path out out of the calligraphic curve. You only need to take care its inner edge still covers the brush. You can even cut the mask path and misalign the ends, as long as the cutting edges fit together.

See the Pen Writing calligraphy: divide up intersections by ccprog (@ccprog) on CodePen.

And thus you can even draw a complex arabic calligraphy, like this one. The origiinal design, the Tughra  of osmanic Sultan Mahmud II. , is by an unknown 19th century calligrapher. Vectorisation has been done by Wikipedia illustrator Baba66 . The animation is my attempt to visualize the position of the arabic letters inside the drawing. It builds upon an earlier version by Baba66. License: Creative Commons Attribution-Share Alike 2.5 .

In contrast to the examples above, this animation uses SMIL , which means it will not work in Internet Explorer and Edge.

The following code snippet shows the advanced method used to run the animations in order and in a repeatable fashion.

mask path {
    fill: none;
    stroke: white;
    stroke-width: 16;
}
.brush {
    fill: #0d33f2;
}
<mask id="mask1" maskUnits="userSpaceOnUse">
  <path stroke-dasharray="160 160" stroke-dashoffset="160" d="...">
    <!-- animation begins after document starts and repeats with a click
         on the "repeat" button -->
    <animate id="animate1" attributeName="stroke-dashoffset"
             from="160" to="0" begin="1s;repeat.click" dur="1.6s" />
  </path>
</mask>
<mask id="mask2" maskUnits="userSpaceOnUse">
  <path stroke-dasharray="350 350" stroke-dashoffset="350" d="...">
    <!-- animation begins at the end of the previous one -->
    <animate id="animate2" attributeName="stroke-dashoffset"
             from="350" to="0" begin="animate1.end" dur="3.5s" />
  </path>
</mask>
<!-- more masks... -->
<mask id="mask15" maskUnits="userSpaceOnUse">
  <path stroke-dasharray="230 230" stroke-dashoffset="230" d="...">
    <!-- insert an artificial pause between the animations, as if the
         brush had been lifted -->
    <animate id="animate15" attributeName="stroke-dashoffset"
             from="230" to="0" begin="animate14.end+0.5s" dur="2.3s" />
  </path>
</mask>

<g class="brush">
  <path id="brush1" d="...">
    <!-- The mask is only applied  after document starts/repeats and until
         the animation has run. This makes sure the brushes are visible in
         renderers that do not support SMIL -->
    <set attributeName="mask" to="url(#mask1)"
         begin="0s;repeat.click" end="animate1.end;indefinite" />
  </path>
  <path id="brush2" d="...">
    <set attributeName="mask" to="url(#mask2)"
         begin="0s;repeat.click" end="animate2.end;indefinite" />
  </path>
  <!-- more paths... -->
  <path id="brush15" d="...">
    <set attributeName="mask" to="url(#mask2)"
         begin="0s;repeat.click" end="animate15.end;indefinite" />
  </path>
</g>