Lesson 16 - Text advanced features
In the previous lesson, we introduced the principles of SDF-based text rendering, experimented with ESDT and MSDF to improve rendering quality, and mentioned the advanced text rendering features that CanvasKit provides compared to Canvas.
In this lesson, we'll first look at rendering methods beyond SDF, then discuss and try to implement features like: decorative lines, shadows, text following paths. Finally, text should not only be renderable but also have good interaction - we'll discuss topics like input boxes, text selection, and A11y.
Let's first look at what text rendering methods are available besides SDF.
Rendering Text with Bezier Curves
Using Figma's SVG export feature, you can see that its text is also rendered using Paths. If we don't consider rendering performance and CJK characters, using Bezier curves to render text is indeed a good choice. To obtain vector information for characters in a browser environment, we can use:
- opentype.js
- use-gpu uses use-gpu-text which is wrapped based on ab-glyph
- More and more applications are using harfbuzzjs, see: State of Text Rendering 2024. For example, font-mesh-pipeline is a simple demonstration
Below we'll show examples of rendering text using opentype.js and harfbuzzjs, both of which support the ttf font format.
opentype.js
opentype.js provides the getPath method, which completes Shaping and obtains SVG path-commands given text content, position, and font size.
opentype.load('fonts/Roboto-Black.ttf', function (err, font) {
const path = font.getPath('Hello, World!', 0, 0, 32); // x, y, fontSize
// convert to svg path definition
});harfbuzzjs
First initialize harfbuzzjs WASM using Vite's ?init syntax. Then load the font file and create a font object.
import init from 'harfbuzzjs/hb.wasm?init';
import hbjs from 'harfbuzzjs/hbjs.js';
const instance = await init();
hb = hbjs(instance);
const data = await (
await window.fetch('/fonts/NotoSans-Regular.ttf')
).arrayBuffer();
blob = hb.createBlob(data);
face = hb.createFace(blob, 0);
font = hb.createFont(face);
font.setScale(32, 32); // Set font sizeThen create a buffer object and add text content. As mentioned before, harfbuzz doesn't handle BiDi, so we need to manually set the text direction. Finally, call hb.shape method to perform Shaping calculation.
buffer = hb.createBuffer();
buffer.addText('Hello, world!');
buffer.guessSegmentProperties();
// TODO: use BiDi
// buffer.setDirection(segment.direction);
hb.shape(font, buffer);
const result = buffer.json(font);Now we have the glyph data, and we can use Path to draw it
result.forEach(function (x) {
const d = font.glyphToPath(x.g);
const path = new Path({
d,
fill: '#F67676',
});
});TeX math rendering
We can use MathJax to render TeX mathematical formulas, convert them to SVG, and then render them using Path. Here we follow the approach from LaTeX in motion-canvas to get SVGElement:
const JaxDocument = mathjax.document('', {
InputJax: new TeX({ packages: AllPackages }),
OutputJax: new SVG({ fontCache: 'local' }),
});
const svg = Adaptor.innerHTML(JaxDocument.convert(formula));
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const $svg = doc.documentElement;Then use the method introduced in Lesson 10 - From SVGElement to Serialized Node to convert SVGElement to graphics and add them to the canvas.
const root = await deserializeNode(fromSVGElement($svg));Text stroke
Both strokeText in Canvas and -webkit-text-stroke in CSS provide text strokes. The good news is that strokes are naturally easy to implement in SDF:
if (strokeWidth > 0.0 && strokeColor.a > 0.0) {
float fillAlpha = smoothstep(buff - gamma_scaled, buff + gamma_scaled, dist);
float strokeThreshold = buff - strokeWidth / fontSize;
float strokeAlpha = smoothstep(strokeThreshold - gamma_scaled, strokeThreshold + gamma_scaled, dist);
vec4 finalColor = mix(strokeColor, fillColor, fillAlpha);
outputColor = finalColor;
opacity *= strokeAlpha;
}The following shows renderings based on SDF and MSDF, and you can see that MSDF stays sharp even when stroked:
Text Decoration
In CSS, early browsers had a crude implementation of text-decoration, as exemplified by underline, from which the following image is taken: Crafting link underlines on Medium

The perfect underline should be visible, but unobtrusive — allowing people to realize what’s clickable, but without drawing too much attention to itself. It should be positioned at just the right distance from the text, sitting comfortably behind it for when descenders want to occupy the same space:

Current browsers have perfected this implementation. In addition, Canvas does not provide this feature, and CanvasKit enhances it with a set of properties that correspond to CSS text-decoration:
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/text-decoration#constituent_properties
// @see https://skia.org/docs/dev/design/text_shaper/#principles
const paraStyle = new CanvasKit.ParagraphStyle({
textStyle: {
decoration: CanvasKit.UnderlineDecoration,
decorationColor,
decorationThickness,
decorationStyle: CanvasKit.DecorationStyle.Solid,
},
});Another interesting implementation underlineJS is based on Canvas.
Use polyline
The decoration line style is controlled by the decorationStyle property:
export type TextDecorationStyle =
| 'solid'
| 'double'
| 'dotted'
| 'dashed'
| 'wavy';
: TextDecorationStyle;In the simplest solid style, for example, we pass in decorationColor and decorationThickness as strokeColor and strokeWidth:
if (instance instanceof Text) {
const {
decorationColorRGB,
decorationThickness,
decorationStyle,
metrics,
} = instance;
u_StrokeColor = [
decorationColorRGB.r / 255,
decorationColorRGB.g / 255,
decorationColorRGB.b / 255,
fo,
];
u_ZIndexStrokeWidth[1] = decorationThickness;
}Decoration style
Polyline itself supports strokeDasharray, so both dotted and dashed can be realized by it. Here we refer to Skia's implementation to set the ratio of dash to gap:
// @see https://github.com/google/skia/blob/main/modules/skparagraph/src/Decorations.cpp#L187
SkScalar scaleFactor = textStyle.getFontSize() / 14.f;
switch (textStyle.getDecorationStyle()) {
case TextDecorationStyle::kDotted: {
dashPathEffect.emplace(1.0f * scaleFactor, 1.5f * scaleFactor);
break;
}
case TextDecorationStyle::kDashed: {
dashPathEffect.emplace(4.0f * scaleFactor, 2.0f * scaleFactor);
break;
}
default: break;
}To be specific, we need to calculate and sample the wavy lines, which we'll do here by generating an SVG Path and using the method described in Lesson 13 - Sampling on a curve:
// @see https://github.com/google/skia/blob/main/modules/skparagraph/src/Decorations.cpp#L215
let d = 'M 0 0';
while (x_start + quarterWave * 2 < line.width) {
d += ` Q ${x_start + quarterWave} ${
wave_count % 2 != 0 ? quarterWave : -quarterWave
} ${x_start + quarterWave * 2} 0`;
x_start += quarterWave * 2;
++wave_count;
}The effect is as follows:
Calculate position
The position is controlled by the property decorationLine:
export type TextDecorationLine =
| 'underline'
| 'overline'
| 'line-through'
| 'none';We refer to the Skia Decorations::calculatePosition implementation for underline as an example:
void Decorations::calculatePosition(TextDecoration decoration, SkScalar ascent) {
switch (decoration) {
case TextDecoration::kUnderline:
if ((fFontMetrics.fFlags & SkFontMetrics::FontMetricsFlags::kUnderlinePositionIsValid_Flag) &&
fFontMetrics.fUnderlinePosition > 0) {
fPosition = fFontMetrics.fUnderlinePosition;
} else {
fPosition = fThickness;
}
fPosition -= ascent;
break;
}
}Export SVG
Unfortunately, as of now (2025.7.9), SVG does not fully support text-decoration. In the right image below, text-decoration-color does not respect the blue color we set, but is overridden by the text color, and text-decoration-style is not supported at all. See: Text decoration of a text svg in html.
Apparentrly, text-decoration-color does not apply to SVG text elements
This is circumvented in Figma by exporting as <path>. We still want to keep <text>, so when exporting SVGs, we can only use <foreignObject> wrappers, see below left:
<foreignObject width="50" height="20">
<span style="text-decoration: underline; text-decoration-color: blue;">
Text
</span>
</foreignObject>Shadows
Pixi.js provides DropShadowFilter, but we can implement it directly in SDF without using post-processing. Use shadowOffset and shadowBlurRadius to control the offset and blurring of the SDF texture.
// @see https://github.com/soimy/pixi-msdf-text/blob/master/src/msdf.frag#L49
vec3 shadowSample = texture2D(uSampler, vTextureCoord - shadowOffset).rgb;
float shadowDist = median(shadowSample.r, shadowSample.g, shadowSample.b);
float distAlpha = smoothstep(0.5 - shadowSmoothing, 0.5 + shadowSmoothing, shadowDist);
vec4 shadow = vec4(shadowColor, shadowAlpha * distAlpha);
gl_FragColor = mix(shadow, text, text.a);Text Along Path
In the Figma community, many users are looking forward to this feature, for example: Make text follow a path or a circle. This feature was recently officially supported: Type text on a path.
In SVG, this can be achieved through textPath, see: Curved Text Along a Path
<path
id="curve"
d="M73.2,148.6c4-6.1,65.5-96.8,178.6-95.6c111.3,1.2,170.8,90.3,175.1,97"
/>
<text width="500">
<textPath xlink:href="#curve"> Dangerous Curves Ahead </textPath>
</text>Skia provides the MakeOnPath method, see Draw text along a path:
const textblob = CanvasKit.TextBlob.MakeOnPath(text, skPath, skFont);
canvas.drawTextBlob(textblob, 0, 0, textPaint);In Mapbox, placing labels along roads and rivers is a common scenario, see Map Label Placement in Mapbox GL

Kittl provides a Easily Type Text On Any Path tool.
See fabricjs - text on path for another implementation. Place each glyph by the arc length at the center of its advance box, then use the tangent at that same distance for its orientation. A Bézier parameter t is generally not a length fraction: point and tangent sampling must use the same arc-length mapping, as described in Lesson 13 - Sampling on a curve.
const centerDistance =
startOffset + alignmentOffset + advanceBefore + advance / 2;
const ratio = centerDistance / totalPathLength;
const point = curve.getPointAt(ratio);
const tangent = curve.getTangentAt(ratio);The advance width positions the next glyph. It differs from the texture width, ink bearings and SDF padding. letterSpacing applies only between adjacent glyphs.
Adjust rotation
The implementation rotates each Quad on the CPU around the glyph's baseline origin, then renders it with SDF/MSDF. Rotating around the texture's bottom edge would displace text differently for each font and size.
const rotation = Math.atan2(tangent[1], tangent[0]);
const x =
point[0] -
(Math.cos(rotation) * advance) / 2 -
Math.sin(rotation) * pathOffset;
const y =
point[1] -
(Math.sin(rotation) * advance) / 2 +
Math.cos(rotation) * pathOffset;This example uses the alphabetic baseline; other textBaseline adjustments also follow the local normal. Bounds are the union of the rotated glyph ink rectangles, so changing content, size, path or offsets updates the selection bounds too.
| Attribute | Behavior |
|---|---|
textAlign | start / left, center, or end / right, relative to the available path length |
startOffset | Moves the aligned text along its reading direction, in document units |
side | left follows the path; right travels from the other end and rotates glyphs while preserving reading order |
pathOffset | Moves the baseline along the local normal; positive values lie 90° clockwise from the reading-direction tangent |
letterSpacing | Adds distance between adjacent glyphs |
On open paths, glyphs whose centers fall beyond either endpoint are omitted instead of wrapping or piling up at an endpoint. A single closed contour can cross its seam at positive or negative offsets, with at most one lap per line. Separate subpaths concatenate their lengths without inventing a line across M gaps. Additional lines follow the local normal at their line-height spacing.
Drag the text or blue handle along the path, and drag orange control points to reshape the curve. Switch between a curve, circle and line, change sides and alignment, or adjust spacing and baseline distance. Handles support arrow keys, Shift for larger steps and Esc to cancel; a canceled touch gesture restores its starting state.
Drag the text or blue handle along the path. Drag orange control points to reshape the curve. Handles support arrow keys; Esc cancels a drag.
This example uses the core SDF text renderer. The path layout and editing improvements retain the existing grapheme pipeline; they do not add full OpenType shaping or ligature support.
ECS also supports these path attributes, with transformer gestures, glyph picking, undo/redo and text editing. Try the ECS path text example; editing uses a straight text box and committing restores the curved layout.
Export SVG
Native SVG supports textPath, as shown below; see Curved Text Along a Path. The exporter shares the canvas layout and emits positioned <tspan> elements with x, y and rotate to preserve side changes, overflow clipping and closed seams across browsers with different textPath side support. JSON serialization preserves the path, side, alignment and offsets for further editing after import.
<path
id="MyPath"
fill="none"
stroke="red"
d="M10,90 Q90,90 90,45 Q90,10 50,10 Q10,10 10,40 Q10,70 45,70 Q70,70 75,50"
></path>
<text>
<textPath href="#MyPath">Quick brown fox jumps over the lazy dog.</textPath>
</text>More Friendly Interaction
Browser-native <textarea>s provide convenient features such as blinking cursors, selections, keyboard control, copy and paste, etc. If you wish to implement these features from scratch based on <canvas>, it would be a very tedious task, e.g. fabricjs - loading custom fonts and google docs, so we won't be choosing this option.
Text Input
Below, from Figma, you can see that the native <textarea> element is used to position itself on the canvas, and when Text is double-clicked, the input box is displayed:

This is also used in excalidraw, see: textWysiwyg.tsx。

We also add a <ic-text-editor> element to make it fit the Text rendering in the canvas as closely as possible. The native <textarea> element is stylistically reset, e.g. not showing borders and backgrounds. The fontFamily, fontSize, and color properties all have corresponding CSS properties that can be set directly, but there are a number of factors that need to be taken into account when using absolute positioning:
@customElement('ic-text-editor')
export class TextEditor extends LitElement {
static styles = css`
:host {
position: absolute;
}
textarea {
position: absolute;
display: none;
margin: 0;
padding: 0;
border: 0;
outline: 0;
resize: none;
background: transparent;
}
`;
@query('textarea')
editable: HTMLTextAreaElement;
render() {
return html`<textarea></textarea>`;
}
}First you need to convert the mouse event position coordinates of the double click to the viewport coordinate system:
const { x, y } = this.api.canvas2Viewport({
x: this.node.x,
y: this.node.y,
});
this.editable.style.left = `${x}px`;
this.editable.style.top = `${y}px`;The current zoom level of the camera then needs to be taken into account:
const { zoom } = this.api.getCamera().read(ComputedCamera);
this.editable.style.transform = `scale(${zoom})`;
this.editable.style.transformOrigin = `left top`;Finally, we want scrolling on the <textarea> element not to trigger the browser's default behavior, but to cut through it and trigger it on the <canvas> element, which performs the camera pan and zoom operations:
handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
const newWheelEvent = new WheelEvent('wheel', {});
$canvas.dispatchEvent(newWheelEvent);
};The effect is as follows:

Calculate size when text changed
The width and height of <textarea> needs to be recalculated and set when typing and pasting text in real time.
Handle Tab
The default behavior of pressing the Tab key in <textarea> is to switch focus to the next element. We want it to be the same as the code editor.
Text Selection
Once you have support for overlaying textarea on Text, it's easy to implement this feature.
Special Effects
Loading Web Fonts
For solutions using Canvas2D API to generate SDF, just use webfontloader to load fonts first, then specify the font using fontFamily.
import WebFont from 'webfontloader';
WebFont.load({
google: {
families: ['Gaegu'], // specify font
},
active: () => {
const text = new Text({
x: 150,
y: 150,
content: 'Hello, world',
fontFamily: 'Gaegu', // specify font
fontSize: 55,
fill: '#F67676',
});
},
});You can also switch to Text mode and select the Gaegu custom font in the home page example:

Material Design on the GPU
Material Design on the GPU introduce a material effect based on SDF text, using normal maps and lighting to simulate ink spreading on paper. We don't need to consider lighting, just use simplex noise to implement it, and add multiple absorption effects:
import { simplex_2d } from './simplex-2d';
import { aastep } from './aastep';
export const absorb = /* wgsl */ `
${aastep}
${simplex_2d}
float absorb(float sdf, vec2 uv, float scale, float falloff) {
float distort = sdf + snoise(uv * scale) * falloff;
return aastep(0.5, distort);
}
`;