Animate Calligraphy with SVG

Avatar of Claus Colloseus
Claus Colloseus on

DigitalOcean provides cloud products for every stage of your journey. Get started with $200 in free credit!

From time to time at Stackoverflow, the question pops up whether there is an equivalent to the stroke-dashoffset technique for animating the SVG stroke that works for the fill attribute. But upon closer inspection, what the questions are really trying to ask is something like 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 do you 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 and then 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 one beneath it. Apply the stroke-dashoffset animation technique to the mask path. The result will look as if the lower path is being “written” directly on the screen in real-time.

The 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, however, 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.

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

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

The most tricky part is to maintain the impression that the drawing is a single continuous stroke. If you cut a smooth path, 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 many line animations can get by with rough math on the length for stroke-dasharray, this scenario requires accurate measurements (although small roundings shouldn’t hurt). As a reminder, you can get them in the DevTools 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 start time and total duration, then set intermediate keyframes between the stroke-dashoffset changes.

Something 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 timing. Keeping with CSS, computations done with Sass might be pretty helpful since it can handle some math.

The mask path (left) and its application (right)

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 solution is to move the mask path 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.

The radius stays large enough

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

And, thus, you can even draw something complex, like the Arabic calligraphy in this example:

See the Pen Tughra Mahmud II – text animation by ccprog (@ccprog) on CodePen.

The original design, the Tughra of Osmanic Sultan Mahmud II., is by an unknown 19th-century calligrapher. The vectorized version was 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. Creative Commons Attribution-Share Alike 2.5.

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;" dur="1.6s" />
<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" />
<!-- 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" />

<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;" end="animate1.end;indefinite" />
  <path id="brush2" d="...">
    <set attributeName="mask" to="url(#mask2)"
         begin="0s;" end="animate2.end;indefinite" />
  <!-- more paths... -->
  <path id="brush15" d="...">
    <set attributeName="mask" to="url(#mask2)"
         begin="0s;" end="animate15.end;indefinite" />

In contrast to the other examples we’ve look at, this animation uses SMIL, which means it will not work in Internet Explorer and Edge.

This article is published in German over at Browser…​unplugged.