Title safe · 1080×1920
Text and animated captions in a browser video editor: let CSS do the drawing
Render titles and word-by-word captions in a web video editor: canvas text versus HTML and CSS, font loading, timing words to speech and exporting text.
Animated captions in JavaScript look simple in a demo. A few words appear on a video, each one lights up as it is spoken, and the whole block sits neatly inside the frame. Building that inside a browser video editor is harder than it looks, because the same words must be drawn in the preview, line up with the speech, and come out of the export as the same pixels.
This guide explains how a browser draws text, why the obvious canvas approach runs out of road, and how letting the browser's own HTML and CSS do the drawing solves most of the problem. It is written for a reader who has never built a video tool. Every term is defined the first time it appears, and every idea is built from the bottom up. The Azelify editor is our worked example: a browser-native video editor that draws every line of on-screen text this way, including the parts of that design we would keep and the parts we would change.
We cover six things in order: what a browser needs to draw text, why a single canvas call is not enough for captions, how fonts must be ready before anything is drawn, how word timing is stored, how an HTML caption becomes one more layer in a frame, and how the editor puts these pieces together.
What does a browser need to draw text?#
Three words from the text world come first. A font is a file that holds the outline of every character it supports, and each drawn shape is called a glyph. Layout is the step that decides where each glyph goes: which words sit on which line, how wide each line is, and where a line breaks. Rasterising is the last step, which turns those outlines into coloured pixels on a screen or in a video frame.
A normal web page does all three for you. The browser knows which font to use, lays out each paragraph, and paints the result in one pass. Line breaking follows a published rule book rather than a guess. The Unicode line breaking algorithm gives each character a class and then decides where a line may end, so a space or a hyphen can end a line while the letters inside a word cannot.[1] Scripts that join letters or stack marks add their own rules. A paragraph on a web page wraps correctly in English, Arabic or Thai because the browser already knows those rules, and the author writes no wrapping code at all.
A video frame is different. It is a bitmap that the editor paints, one frame at a time, and a canvas (opens in a new tab) is the browser's painting surface for that job. A 2D canvas has commands for paths, images and text, but it has no document, no paragraphs and no layout engine. Text on a canvas is one call that draws one string at one point. That single difference shapes everything in this article.
Read the diagram from left to right. Each box consumes the output of the box before it. The two steps that most often go wrong are the second and the third: a caption drawn before its font arrives gets the wrong line breaks, and a caption laid out at the wrong width wraps in the wrong places. We come back to both.
Why is canvas fillText not enough for animated captions?#
The canvas method for text is fillText. It takes a string, an x position and a y position, and paints the string with the current font and colour.[2] Its optional fourth argument, maxWidth, limits how wide the text may be, but it does this by shrinking the text horizontally. It does not break the text into lines. To measure a string before drawing it, you call measureText, which returns a TextMetrics object whose width property is the advance of the string in pixels.[3][4] The MDN tutorial on drawing text shows the basic call if you want to try it.[5]
Because fillText draws one line, a caption needs a wrapper. The smallest useful one measures each candidate line and starts a new line when the next word will not fit:
// A hand-written word wrap for canvas text.
// Canvas draws one line per call, so we measure words and break them ourselves.
function wrapWords(ctx, words, maxWidth) {
const lines = [];
let line = "";
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth) {
line = candidate; // the word still fits on the current line
} else {
lines.push(line); // close the current line
line = word; // start a new line with this word
}
}
if (line) lines.push(line);
return lines;
}
The function works for plain English in one font. It does not cover everything a caption needs. It breaks only at spaces, so a hyphenated compound or a word followed by a dash will not break where a reader expects. It cannot wrap Thai, which writes words without spaces. It measures each candidate as one string, so if the third word must be a different colour, you must draw the words one at a time at their own positions. And if one word should grow while the next one settles, each letter needs to be its own unit that you position and animate.
This is why hand-rolled canvas text engines tend to grow large. Each feature needs its own measuring, positioning and drawing code, and the code must match the browser's behaviour closely enough that readers do not notice the difference. A better question is whether the browser can do the layout for us, so that we only have to take the picture it produces.
How do fonts load before text is drawn?#
A web font is a file that the page downloads when it needs it. If canvas text is drawn before that file arrives, the browser uses a fallback font. The width you measured belongs to the wrong face, the line breaks are wrong, and the caption jumps when the real font finally appears. The CSS Font Loading API gives the page a way to wait.[6] A FontFace (opens in a new tab) object describes one font file. You add it to document.fonts, call load() on it, and then wait on document.fonts.ready, which resolves once every font the page uses has finished loading.[7]
// Describe a font file, register it, and wait until it is ready to measure with.
async function loadCaptionFont(name, url) {
const face = new FontFace(name, `url(${url})`); // describe the font file
document.fonts.add(face); // make the page aware of it
await face.load(); // download and parse the file
await document.fonts.ready; // wait for every other font too
return face; // now safe to measure and draw
}
Two details matter for video. The first is that the font you measure with must be the font you draw with. Load first, then measure, and never reuse a width that was measured while a fallback was showing. The second is that a font loaded for the page is not automatically available inside every image or every export. That detail returns in the section on HTML captions, so keep it in mind.
How do you time animated captions word by word?#
A caption track is timed data. The W3C WebVTT format, which browsers use for subtitles on video, stores each caption as a cue with a start time and an end time on the same clock as the video.[8] Word-level captions go one level deeper. Each word becomes its own item with its own start and end, and the list is sorted by time. Speech-recognition tools usually produce a list like this, and it is often called a token list. When a tool gives no timing for a word, an editor can estimate one by sharing the segment's duration across its words. Estimated timing is less exact, so it should be labelled as an estimate rather than presented as a measurement.
// Each word carries its own timing, in seconds, on the clip's clock.
const words = [
{ text: "Caption", start: 0.00, end: 0.42 },
{ text: "timing", start: 0.42, end: 0.90 },
{ text: "matters", start: 0.90, end: 1.45 },
];
// Given the playhead time t, return the index of the last word that has started.
// Returns -1 before the first word begins. The list must be sorted by start time.
function activeWordIndex(words, t) {
let active = -1;
for (let i = 0; i < words.length; i++) {
if (words[i].start <= t) active = i; // this word has started by time t
else break; // sorted list: no later word has started
}
return active;
}
The function is pure, meaning that the same time always gives the same answer and nothing is remembered between calls. That property is what makes captions testable and exportable. Given any frame time, you can compute the active word without replaying the frames before it. A linear scan is fine for a short caption. For a long transcript, a binary search over the same sorted list gives the same answer faster.
The style decides how the active word looks, while the timing stays as data. A plain caption shows every word the same way. A highlight style gives the active word a colour or a scale. A "said" style keeps spoken words visible but dimmer than the words still to come. Keep the timing and the look in separate parts of your data, so that a new style never forces you to re-time the transcript.
Can you let HTML and CSS draw the text?#
There is a route that avoids most of the work above. Write the caption as ordinary HTML and CSS, so the browser lays it out, wraps it and draws its fonts. Then capture the result as pixels and treat it as an image. The bridge is SVG. A foreignObject element lets HTML live inside an SVG document,[9] and an SVG document can be loaded as an image, which can then be drawn onto a canvas.
// Turn an HTML caption into pixels: wrap it in SVG, load it as an image, draw it.
// A sketch only. Real code must inline its fonts and check every step.
function htmlToCanvas(ctx, html, css, w, h, x, y) {
const markup = `
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml">
<style>${css}</style>${html}
</div>
</foreignObject>
</svg>`;
const blob = new Blob([markup], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { // runs once the browser has rendered the SVG
ctx.drawImage(img, x, y); // the canvas now holds the HTML caption
URL.revokeObjectURL(url); // free the temporary address
};
img.src = url;
}
The sketch has real limits. The image loads asynchronously, so the draw belongs inside the load callback. Fonts used inside the SVG must be inlined as data rather than fetched, and browsers differ on which external resources an SVG image may use. If a load is blocked for those reasons, the canvas can be marked as tainted, which stops the pixels from being read back for export. Production code therefore inlines everything and checks each step.
The payoff is that an HTML caption uses CSS knowledge that many people already have. Colours, shadows, transitions, font weights and percentages all work as they do on a web page. Size can also follow the frame. Container query length units (opens in a new tab) let a value be a fraction of the width of a container, where 1cqw is one percent of that width.[10] A caption whose font size is set in those units grows and shrinks with its frame instead of staying fixed in pixels.
The cost is weight. Rasterising HTML is heavier than one fillText call, and it must be repeated whenever the text or the time changes. The next sections show how to keep that cost under control.
Step-by-step: from a caption track to an exported frame#
Here is the whole path for one caption, from timing data to a frame in an exported file. Each step can be tested on its own, and each one has a clear input and output.
Step 1: Parse the timing data#
Read the word list into memory and sort it by start time. Check that no word ends before it starts and that every time falls inside the clip. A bad timing is cheapest to catch here, before any drawing has happened.
Step 2: Load the fonts#
Call the font loader and wait for the fonts to be ready. Do this once for each font, not once per frame. A font that is already loaded returns at once, so the cost is paid only the first time.
Step 3: Lay out the words#
Let the browser lay out the words inside a box of the right width. Measure the rendered words rather than the box that holds them, because a line that is too wide spills out of its box and the box then understates the text.
Step 4: Seek to the frame time#
For frame time t, put every word into the state it has at t: the active word, the words already said, and the words still to come. Each state is a function of t, so the same frame always produces the same picture, however the playhead arrived there.
Step 5: Snapshot and composite#
Capture the laid-out block as pixels, then place the image in the frame like any other layer, with its own position, scale, rotation and blend mode. The caption now behaves like a video or an image layer for the rest of the compositor.
Step 6: Export the same frame#
The export loop calls the same steps for each frame index in order. A preview frame and an exported frame are then drawn by the same code. If the two ever differ, that difference is a bug you can find, rather than two systems drifting apart over time.
How the Azelify editor handles text and captions#
The Azelify editor makes the same bet as the sketches above: the browser draws the text. Every line of on-screen text is a text clip that wears a style. A style is a small web page's worth of design, made of scoped CSS, a little markup, the fonts it uses and the variables a person may change. The browser lays out and paints that style, and the editor has no second text renderer for captions.
To draw a frame, the editor mounts the style's DOM, pauses every CSS animation and positions each one from the clip's time. A frame at time t is therefore a pure function of t. The result is snapshotted to a canvas, and the compositor places that canvas like any other layer. Preview and export draw the same pixels, because both use the same path. This is the design decision the rest of this section explains.
Sizing follows the frame. Text sits in a design box inside the title-safe area, and type is sized in container units against the frame's width. A caption set at a given size therefore scales when the frame changes shape, which keeps the composition consistent across the aspect ratios people export.
Building it taught us two lessons about layout. The first concerns wrapping. When a line is allowed to wrap, the editor wraps only between words. The animation needs each letter in its own box, and a box per letter gives the browser a new place to break inside a word. Before the fix, a long word could break in the middle. Forbidding breaks inside each word removed the problem.
The second concerns measurement. A text block's size must come from the words at rest, not from the group that holds them. A forced line wider than the wrap width spills out of its group, so a box drawn from the group outlines less text than was painted. The rule we took from this is to measure what you draw.
Those choices have costs. A pointer drag that changes the wrap width re-measures the text on every move. The editor answers from a cache and re-lays out only when the width or size really changes. Each clip has one stage and one clock, so renders of a single clip run one after another. We accept that, because a clock that seeks cleanly is worth more to us than parallel draws of the same clip.
Fonts follow the same discipline. The snapshot inlines font data, so a frame does not depend on the network at the moment it is drawn. Uploaded fonts are checked in the browser and cached by a hash of their content. If one face fails to load, that clip reports a fault while the rest of the frame still draws.
Word timing comes in two grades. Aligned timing comes from the speech and is accurate to the word. Estimated timing is spread across a segment when no alignment exists. A timed style keeps each word inside its spoken window, so a word appears when it is said. The style decides the look, and the timing stays as data.
Which text approach should you pick?#
The table compares the three approaches covered so far. The right one depends on what the words must do, and the decision tree below gives a quick way to choose.
| Approach | Wraps lines | Per-word looks | Fonts and scripts | Relative cost |
|---|---|---|---|---|
| Canvas fillText | No, one line per call | Only by drawing each word yourself | You load fonts and measure each string | Lowest per call |
| Hand-written canvas layout | Yes, with your own rules | Yes, with your own code | You own every script rule | Grows with each feature |
| HTML and CSS snapshot | Yes, by the browser's rules | Yes, with CSS | The browser shapes text, fonts must be inlined | Highest per frame |
Read the table row by row. Plain fillText is cheap but only suits one line in one style. A hand-written layout gives full control, but the control is paid for in code you must maintain for every script. The HTML and CSS route costs the most per frame, and in return it gives the browser's own layout and CSS looks for free. For most captions the last route is the one that keeps the preview and the export in agreement without extra rules.
Safe areas for vertical captions#
Captions for phone video also need a boundary. A title-safe zone is a rectangle inset from the edges of the frame, so that text sits clear of the parts of the picture a screen may hide or crop.[11] Short-form apps also place their own buttons and labels over the video, so the margins that people use for phones are larger than the old broadcast figures. Our older guides cover the numbers in detail: see the safe zones guide and captions for vertical video.
The figure below draws a vertical frame of 1080 by 1920 pixels with a title-safe margin and a caption lane inside it.
Treat the margins in the figure as a starting point rather than a rule. Each platform overlays its own controls, and those controls change over time. Check the result on a phone at the size people will see it, and adjust the lane until nothing important is hidden.
Frequently asked questions#
How do I add animated captions to a video in JavaScript?#
Store each word with a start time and an end time, and draw the caption with HTML and CSS. For each frame, use the playhead time to choose the active word, then capture the laid-out text to a canvas. Load the fonts before you measure, so that the line breaks match what you draw.
Why does canvas fillText not wrap long captions?#
The fillText call draws a single line at one position. Its optional width argument shrinks the text rather than breaking it, so you must split the words into lines yourself. Letting the browser's layout engine do that split is usually less work and more accurate.
Do I need to wait for fonts before drawing text on a canvas?#
Yes. If you draw before a web font has loaded, the browser uses a fallback, and every measurement and line break is for the wrong typeface. Wait for the font with the FontFace API and document.fonts.ready, and only then measure.
What is the difference between word-level and line-level captions?#
A line-level caption shows a whole cue at once, with one start and one end. A word-level caption keeps a start and an end for each word, which lets a style highlight the spoken word. Word-level data needs more storage and accurate alignment, but it allows effects that a line-level cue cannot express.
Is WebVTT enough for animated captions?#
WebVTT is a good interchange format for timed captions, and browsers support it for subtitles on video. It does not describe per-word looks or motion. An editor usually keeps richer word data internally and writes WebVTT as one of its outputs.
Can the browser export burned-in captions as video frames?#
Yes. Draw each frame with the same caption path that the preview uses, encode the frames as video, and then mux them with the audio. Mux means putting the encoded video and audio into one container file. Using one code path for preview and export is what keeps the two in agreement.
Where to go next#
Text is a layer, so its placement and blending depend on how the browser composites layers. Read video compositing with canvas and WebGL to see how a caption canvas is placed, blended and masked in a frame. For the timing side, frame-accurate video playback in JavaScript explains how the playhead clock decides which word is active on any given frame.
Keep reading
Compositing video layers with Canvas and WebGL: transforms, blends and effects
Composite video layers in the browser: Canvas 2D versus WebGL, transform matrices, blend modes, shader effects and drawing each frame in the right order.
Captions people can actually read: a practical guide for vertical video ads
Set caption timing, line length, placement and contrast for vertical ads using broadcast guidance and accessibility ratios, then check it on a phone.
More in Browser video editor
Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
Build a playback loop for a web video editor: one master clock, requestAnimationFrame pacing, dropped-frame handling and keeping audio and video in sync.
Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
Use the Web Audio API in a video editor: an audio graph for live mixing, OfflineAudioContext for export, waveform drawing and loudness normalisation.
WebCodecs tutorial: decoding video frames in the browser without a video tag
Decode video frame by frame with the WebCodecs VideoDecoder: demux samples, configure the decoder, seek to keyframes and release VideoFrame memory.