Skip to main content

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.

15 min readEngineering

Part 9 of 15Build a browser video editor

Title safe · 1080×1920azelify
Engineering

Text and animated captions in a browser video editor: let CSS do the drawing

Key takeaways

  • Canvas fillText cannot wrap lines, shape every script well or animate per word, so hand-rolled text engines grow large very quickly.
  • The browser's own HTML and CSS layout already handles fonts, wrapping, emoji and effects; draw text there and capture it for export.
  • Word-level captions are timed data: each word carries a start and end, and the style decides how the active word looks.

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.

Fig. 01
A caption passes through six steps before it reaches the export frameIllustrative pipeline read from left to right. A caption track with word timings feeds a font loading step, then a layout step that sets line breaks, then a seek to the frame time, then a snapshot of the laid-out text as an image, and finally a composite that places the image as one layer in the frame.
Caption trackwords with start, end
Load fontswait before drawing
Lay out wordsline breaks, widths
Seek to time tsame state every time
SnapshotHTML to an image
Compositeplace like a layer
  1. 01 → Caption trackwords with start, end
  2. 02 → Load fontswait before drawing
  3. 03 → Lay out wordsline breaks, widths
  4. 04 → Seek to time tsame state every time
  5. 05 → SnapshotHTML to an image
  6. 06 → Compositeplace like a layer
A caption passes through six steps before it reaches the export frame
Illustrative, not data[mdn-font-loading]

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.

ApproachWraps linesPer-word looksFonts and scriptsRelative cost
Canvas fillTextNo, one line per callOnly by drawing each word yourselfYou load fonts and measure each stringLowest per call
Hand-written canvas layoutYes, with your own rulesYes, with your own codeYou own every script ruleGrows with each feature
HTML and CSS snapshotYes, by the browser's rulesYes, with CSSThe browser shapes text, fonts must be inlinedHighest 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.

Fig. 02
Choose a text approach by what the words must doDecision tree. If the words need wrapping or per-word styling, draw them with HTML and CSS. If not, and the text must match the export exactly, use the same HTML path. Otherwise plain fillText may be enough, with caveats about wrapping and fonts.yesnoyesno
Need wrapping or per-word styling?
Draw with HTML and CSS
Must text match export exactly?
Same HTML path, same pixels
fillText may be enough
  1. Need wrapping or per-word styling?
    1. yes → Draw with HTML and CSS
    2. no → Must text match export exactly?
      1. yes → Same HTML path, same pixels
      2. no → fillText may be enough
Choose a text approach by what the words must do
Illustrative, not data

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.

Fig. 03
Illustrative 1080 by 1920 frame with title-safe margins and a caption laneA tall vertical frame, 1080 by 1920 pixels, with a title-safe margin of 192 pixels at the top and bottom and 108 pixels on each side. A caption lane sits in the lower part of the safe area. The margins are illustrative and are not a platform specification.Title safe864 × 1536Illustrative 1080 by 1920 frame with title-safe margins and a caption laneA tall vertical frame, 1080 by 1920 pixels, with a title-safe margin of 192 pixels at the top and bottom and 108 pixels on each side. A caption lane sits in the lower part of the safe area. The margins are illustrative and are not a platform specification.Caption lane864 × 300
Illustrative 1080 by 1920 frame with title-safe margins and a caption lane
Illustrative, not data[wiki-overscan]

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.

Sources

  1. Unicode Consortium, “UAX #14: Line Breaking Properties,” www.unicode.org (opens in a new tab) (accessed 2026-10-13). ↩

  2. MDN Web Docs, “CanvasRenderingContext2D: fillText() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  3. MDN Web Docs, “CanvasRenderingContext2D: measureText() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  4. MDN Web Docs, “TextMetrics,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  5. MDN Web Docs, “Drawing text,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  6. MDN Web Docs, “CSS Font Loading API,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  7. MDN Web Docs, “Document: fonts property,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  8. W3C, “WebVTT: The Web Video Text Tracks Format,” www.w3.org (opens in a new tab) (accessed 2026-10-13). ↩

  9. MDN Web Docs, “foreignObject element,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  10. MDN Web Docs, “CSS container queries,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  11. Wikipedia, “Overscan,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

Build a browser video editor

In this series

  1. 01How to build a video editor in the browser: the complete architecture
  2. 02Designing the project file for a web video editor: schema, versions and autosave
  3. 03Undo and redo in a web editor: the command pattern, explained from zero
  4. 04How a video editor timeline works: tracks, clips, trims and overlaps
  5. 05Importing video files in the browser: probing, codecs, proxies and storage
  6. 06WebCodecs tutorial: decoding video frames in the browser without a video tag
  7. 07Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
  8. 08Compositing video layers with Canvas and WebGL: transforms, blends and effects
  9. 09Text and animated captions in a browser video editor: let CSS do the drawing
  10. 10Drag, resize and rotate handles for a canvas editor: building the transform gizmo
  11. 11Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
  12. 12Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
  13. 13Keyframe animation in a web editor: interpolation, easing curves and motion tracking
  14. 14How to test a video renderer: golden images, oracles and property-based tests
  15. 15FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack

Caption a clip word by word

Add animated captions, then preview them inside a phone-safe frame.

Open the studio

Keep reading

More in Browser video editor

All guides