TextFormat in HTML: the textformatupdate event, and the Outlook mix-up

In a web page, textformatupdate is an event of the EditContext API. It tells your code which part of the text being composed with an IME should be underlined, and how.

In the browser, textformatupdate is an event of the EditContext API. It fires while someone composes text with an Input Method Editor (IME), and it tells your code which part of the text to underline and in what style.

Searched "outlook textformat html"? Outlook's own setting for an email body, olFormatHTML in VBA, is a separate thing. It is covered in Send HTML email in Outlook. This guide is about the web event.

MDN marks the EditContext API as experimental with limited availability, so check its compatibility table before relying on it. Try it first. Click the box and type.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>textformatupdate basics</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  #editor {
    min-height: 56px; padding: 12px 14px; box-sizing: border-box;
    background: #fff; border: 2px solid #d5d9e0; border-radius: 10px;
    font-size: 22px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere;
    caret-color: transparent;  /* this small demo draws no caret of its own */
  }
  #editor:focus { outline: none; border-color: #2563eb; }
  #editor:empty::before { content: "Click here, then type"; color: #9aa3b2; }
  p { font-size: 13.5px; line-height: 1.5; margin: 12px 0 8px; }
  pre {
    margin: 0; padding: 10px 12px; min-height: 110px; box-sizing: border-box;
    background: #1d2330; color: #e6edf7; border-radius: 8px;
    font: 12.5px/1.55 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
  }
</style>
</head>
<body>
<div id="editor"></div>
<p>A plain keyboard fires <b>textupdate</b>. An IME (Japanese, Chinese, Korean) also fires <b>textformatupdate</b> while you compose.</p>
<pre id="log"></pre>

<script>
  const editor = document.getElementById('editor');
  const log = document.getElementById('log');
  const lines = [];
  function note(msg) {
    lines.unshift(msg);
    lines.length = Math.min(lines.length, 6);
    log.textContent = lines.join('\n');
  }

  if (!('EditContext' in window)) {
    note('This browser has no EditContext, so there is no textformatupdate event here.');
  } else {
    const ctx = new EditContext();
    editor.editContext = ctx;  // the div is now focusable and typing goes to ctx, not to the DOM

    // The browser does not draw anything. Redraw the div from ctx.text yourself.
    function draw(formats) {
      editor.textContent = '';
      let pos = 0;
      for (const f of formats) {
        editor.append(ctx.text.slice(pos, f.rangeStart));
        const span = document.createElement('span');
        span.textContent = ctx.text.slice(f.rangeStart, f.rangeEnd);
        span.style.textDecoration = f.underlineStyle === 'none' ? 'none' : 'underline ' + f.underlineStyle;
        span.style.textDecorationThickness = f.underlineThickness === 'thick' ? '3px' : '1px';
        editor.append(span);
        pos = f.rangeEnd;
      }
      editor.append(ctx.text.slice(pos));
    }

    ctx.addEventListener('textupdate', (e) => {
      draw([]);
      note('textupdate: ' + JSON.stringify(e.text));
    });

    ctx.addEventListener('textformatupdate', (e) => {
      const formats = e.getTextFormats();  // an array of TextFormat objects
      draw(formats);
      note('textformatupdate: ' + (formats.map((f) =>
        f.rangeStart + '-' + f.rangeEnd + ' ' + f.underlineThickness + ' ' + f.underlineStyle
      ).join(' | ') || 'no formats'));
    });

    ctx.addEventListener('compositionstart', () => note('compositionstart'));
    ctx.addEventListener('compositionend', () => note('compositionend'));
    note('Ready. Type in the box above.');
  }
</script>
</body>
</html>
A div with an EditContext. The log shows each event. Typing with an IME also shows textformatupdate lines.

A plain keyboard only produces textupdate lines. MDN notes that a textformatupdate listener is only called when text is composed with an IME window or another platform-specific editing surface.

To see real formats, switch your system to an IME such as Japanese, Chinese or Korean and type in the box.

What EditContext is, and why this event exists

For a <textarea>, an <input> or a contenteditable element, the browser supplies the text edit context for you. For a custom editable region, such as text you draw on a <canvas>, you supply it with EditContext.

The user types, the OS passes it on, EditContext reports it, and your code draws it.
The user types, the OS passes it on, EditContext reports it, and your code draws it.

Once an EditContext is attached, the browser does not change the DOM when the user types. Your code owns the drawing: the text, the selection, and the underlines an IME asks for. That last job is what textformatupdate is for.

The smallest code

Two lines of setup, one listener:

const ctx = new EditContext();
editor.editContext = ctx;   // editor is a div or a canvas

ctx.addEventListener('textformatupdate', (e) => {
  for (const f of e.getTextFormats()) {
    console.log(f.rangeStart, f.rangeEnd, f.underlineStyle, f.underlineThickness);
  }
});
  1. Create the context. new EditContext() holds the text, the selection and the composition state.
  2. Attach it. Assign it to editor.editContext. This makes the element focusable and routes typing to the context.
  3. Redraw on textupdate. Rebuild what is on screen from ctx.text.
  4. Read formats on textformatupdate. getTextFormats() returns an array of TextFormat objects. Loop over it.
  5. Draw the underlines. Turn each range into a line.

The event is a TextFormatUpdateEvent. It carries an array of formats, so loop over it with for...of instead of reading only the first item.

What a TextFormat contains

Each TextFormat is one range of text and the underline the IME wants on it.

Property What it says Values
rangeStart Where the formatted range starts A position in the EditContext text
rangeEnd Where the range ends A position in the EditContext text
underlineStyle The style of the underline none, solid, dotted, dashed, wavy
underlineThickness The thickness of the underline none, thin, thick
A format covers positions 2 to 5 of the text. The event carries the numbers and the style, and you draw the line.
A format covers positions 2 to 5 of the text. The event carries the numbers and the style, and you draw the line.

The event does not draw anything. The names solid, dotted, dashed and wavy are also CSS text-decoration-style values, but how you turn style and thickness into a line is your choice.

You cannot make an IME on demand, so the next example fakes one. It builds a TextFormat by hand from the controls and draws it with the same code a real listener would use.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TextFormat values</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .preview {
    padding: 14px 16px; background: #fff; border: 2px solid #d5d9e0; border-radius: 10px;
    font-size: 26px; line-height: 1.6; min-height: 44px; white-space: pre-wrap; overflow-wrap: anywhere;
  }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 14px 0; }
  label { display: block; font-size: 12.5px; font-weight: 600; color: #374151; }
  input, select {
    display: block; width: 100%; box-sizing: border-box; margin-top: 4px; padding: 7px 8px;
    font: 15px system-ui, sans-serif; border: 1px solid #c7ccd6; border-radius: 6px; background: #fff;
  }
  .wide { grid-column: 1 / -1; }
  pre {
    margin: 0; padding: 10px 12px; background: #1d2330; color: #e6edf7; border-radius: 8px;
    font: 12.5px/1.55 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
  }
  small { display: block; margin-top: 8px; color: #6b7280; font-size: 12.5px; line-height: 1.45; }
</style>
</head>
<body>
<div class="preview" id="preview"></div>

<div class="grid">
  <label class="wide">Text <input id="text" value="Hello composition"></label>
  <label>rangeStart <input id="start" type="number" min="0" value="0"></label>
  <label>rangeEnd <input id="end" type="number" min="0" value="5"></label>
  <label>underlineStyle
    <select id="style"><option>solid</option><option>dotted</option><option>dashed</option><option>wavy</option><option>none</option></select>
  </label>
  <label>underlineThickness
    <select id="thick"><option>thin</option><option>thick</option><option>none</option></select>
  </label>
</div>

<pre id="code"></pre>
<small id="note"></small>

<script>
  const $ = (id) => document.getElementById(id);

  // The same drawing code a real textformatupdate handler would use
  function draw(box, text, formats) {
    box.textContent = '';
    let pos = 0;
    for (const f of formats) {
      box.append(text.slice(pos, f.rangeStart));
      const span = document.createElement('span');
      span.textContent = text.slice(f.rangeStart, f.rangeEnd);
      span.style.textDecoration = f.underlineStyle === 'none' ? 'none' : 'underline ' + f.underlineStyle;
      span.style.textDecorationThickness = f.underlineThickness === 'thick' ? '3px' : '1px';
      box.append(span);
      pos = f.rangeEnd;
    }
    box.append(text.slice(pos));
  }

  const hasTextFormat = typeof TextFormat === 'function';

  function update() {
    const text = $('text').value;
    const rangeStart = Math.min(Math.max(+$('start').value || 0, 0), text.length);
    const rangeEnd = Math.min(Math.max(+$('end').value || 0, rangeStart), text.length);
    const init = { rangeStart, rangeEnd, underlineStyle: $('style').value, underlineThickness: $('thick').value };

    // A real IME hands you TextFormat objects. Here we build one by hand.
    const format = hasTextFormat ? new TextFormat(init) : init;

    draw($('preview'), text, [format]);
    $('code').textContent = 'new TextFormat(' + JSON.stringify(init, null, 2) + ')';
    $('note').textContent = hasTextFormat
      ? 'This button panel fakes an IME: the browser did not send these formats.'
      : 'TextFormat is not available in this browser, so a plain object stands in for it.';
  }

  document.querySelectorAll('input, select').forEach((el) => el.addEventListener('input', update));
  update();
</script>
</body>
</html>
Change the range, style and thickness. The panel builds a TextFormat and draws it. The browser sends nothing here.

A finished example: a canvas editor

A canvas has no text input of its own, so it is the textbook case. This editor draws its text with fillText, draws a caret, and draws whatever underlines arrive in textformatupdate.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Canvas text editor with EditContext</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  canvas { display: block; width: 100%; height: 90px; background: #fff; border: 2px solid #d5d9e0; border-radius: 10px; box-sizing: border-box; }
  canvas:focus { outline: none; border-color: #2563eb; }
  p { font-size: 13.5px; line-height: 1.5; margin: 12px 0 0; }
  pre {
    margin: 10px 0 0; padding: 10px 12px; background: #1d2330; color: #e6edf7; border-radius: 8px;
    font: 12.5px/1.55 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
  }
</style>
</head>
<body>
<canvas id="c"></canvas>
<p id="msg">Click the canvas and type. Left and right arrows move the caret.</p>
<pre id="info"></pre>

<script>
  const canvas = document.getElementById('c');
  const info = document.getElementById('info');
  const g = canvas.getContext('2d');
  const SIZE = 28, X = 14, Y = 52;
  let formats = [];

  if (!('EditContext' in window)) {
    document.getElementById('msg').textContent = 'This browser has no EditContext, so this editor cannot run here.';
  } else {
    const ec = new EditContext();
    canvas.editContext = ec;  // a canvas has no text input of its own; this gives it one

    const widthOf = (s) => g.measureText(s).width;

    function draw() {
      g.clearRect(0, 0, canvas.width, canvas.height);
      g.font = SIZE + 'px system-ui, sans-serif';
      g.fillStyle = '#1d2330';
      g.strokeStyle = '#1d2330';
      g.fillText(ec.text, X, Y);

      // Underlines the IME asked for
      for (const f of formats) {
        if (f.underlineStyle === 'none') continue;
        const x1 = X + widthOf(ec.text.slice(0, f.rangeStart));
        const x2 = X + widthOf(ec.text.slice(0, f.rangeEnd));
        g.lineWidth = f.underlineThickness === 'thick' ? 3 : 1;
        g.setLineDash(f.underlineStyle === 'dotted' ? [2, 3] : f.underlineStyle === 'dashed' ? [8, 4] : []);
        g.beginPath();
        g.moveTo(x1, Y + 5);
        if (f.underlineStyle === 'wavy') {
          for (let x = x1 + 3, up = false; x < x2; x += 3, up = !up) g.lineTo(x, Y + (up ? 5 : 8));
        }
        g.lineTo(x2, Y + 5);
        g.stroke();
        g.setLineDash([]);
      }

      // Caret
      if (document.activeElement === canvas) {
        const cx = X + widthOf(ec.text.slice(0, ec.selectionEnd));
        g.fillRect(cx, Y - SIZE + 4, 2, SIZE);
      }
      tellOS();
    }

    // Tell the OS where the editor and caret are, so an IME window opens next to them
    function tellOS() {
      const box = canvas.getBoundingClientRect();
      const cx = box.left + X + widthOf(ec.text.slice(0, ec.selectionEnd));
      ec.updateControlBounds(box);
      ec.updateSelectionBounds(DOMRect.fromRect({ x: cx, y: box.top + Y - SIZE, width: 1, height: SIZE + 6 }));
    }

    function resize() {
      const dpr = window.devicePixelRatio || 1;
      canvas.width = Math.round(canvas.clientWidth * dpr);
      canvas.height = Math.round(canvas.clientHeight * dpr);
      g.setTransform(dpr, 0, 0, dpr, 0, 0);
      draw();
    }

    ec.addEventListener('textupdate', () => { formats = []; draw(); });
    ec.addEventListener('textformatupdate', (e) => {
      formats = e.getTextFormats();
      info.textContent = 'textformatupdate: ' + formats.length + ' format(s)';
      draw();
    });
    ec.addEventListener('characterboundsupdate', (e) => {
      const box = canvas.getBoundingClientRect();
      const rects = [];
      for (let i = e.rangeStart; i < e.rangeEnd; i++) {
        rects.push(DOMRect.fromRect({
          x: box.left + X + widthOf(ec.text.slice(0, i)), y: box.top + Y - SIZE,
          width: widthOf(ec.text.charAt(i)), height: SIZE + 6,
        }));
      }
      ec.updateCharacterBounds(e.rangeStart, rects);
    });

    // Arrow keys are not moved for you: update the selection yourself
    canvas.addEventListener('keydown', (e) => {
      const to = e.key === 'ArrowLeft' ? ec.selectionEnd - 1 : e.key === 'ArrowRight' ? ec.selectionEnd + 1 : null;
      if (to === null) return;
      const pos = Math.min(Math.max(to, 0), ec.text.length);
      ec.updateSelection(pos, pos);
      draw();
    });
    canvas.addEventListener('focus', draw);
    canvas.addEventListener('blur', draw);
    window.addEventListener('resize', resize);
    resize();
  }
</script>
</body>
</html>
A canvas text editor on EditContext. It draws the text, a caret and IME underlines, and tells the OS where the caret is.
  • Draw the text: clear the canvas and call fillText with ctx.text on every textupdate.
  • Draw the underlines: measure the text before rangeStart and rangeEnd with measureText, then stroke a line between the two x positions.
  • Move the caret yourself: this editor handles the left and right arrow keys and calls updateSelection().
  • Tell the OS where you are: updateControlBounds() and updateSelectionBounds() position the IME window, and characterboundsupdate asks for per-character rectangles.

For the drawing side, see drawing on a canvas. If the text looks soft on a high-density screen, blurry canvas explains the scaling used in the example.

Check support before you use it

Because the API is experimental, test for it first and keep a fallback:

if (!('EditContext' in window)) {
  // fall back to a textarea or a contenteditable element
}

Each demo on this page checks for the API it needs and prints a message instead of failing. If both EditContext and contenteditable are set on an element, the spec says the EditContext behavior wins.

Outlook's TextFormat versus this event

The two share a word and nothing else.

Outlook (VBA) Browser (JavaScript)
Name MailItem.BodyFormat textformatupdate
What it does Sets the body format: Plain, Rich Text or HTML Reports which text to underline during IME composition
Runs in Outlook A web page
The Outlook body format setting on the left, the browser event on the right.
The Outlook body format setting on the left, the browser event on the right.

To send an HTML email from Outlook, read Send HTML email in Outlook. If the result looks wrong once it arrives, HTML not displaying correctly in Outlook covers it.

When it does not work

What you see Cause Fix
The event never fires while you type A plain keyboard does not compose through an IME Test with an IME, or fake formats as in the second example
EditContext is not defined The browser has no EditContext Feature-check and fall back to a textarea
NotSupportedError on assignment The element type cannot hold an EditContext Use a div, a canvas or another listed element
Typing changes nothing on screen The browser does not update the DOM for you Redraw from ctx.text in textupdate
The IME window opens far from the text The OS was not told where the text is Call updateControlBounds and updateSelectionBounds
The arrow keys do not move the caret You draw the selection yourself Call updateSelection and redraw
An error when attaching to a second element One EditContext belongs to one element Create one EditContext per element

An editor that reacts to typing is easier to show than to describe. A screenshot cannot be typed into.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can type in the box themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is textformatupdate in JavaScript?

It is an event of the EditContext API. It fires while the user composes text with an Input Method Editor (IME), and its getTextFormats() method returns TextFormat objects: a text range plus an underline style and thickness the IME wants applied.

Why does textformatupdate never fire when I type?

MDN notes that the listener is only called when text is composed with an IME window or another platform-specific editing surface. A plain keyboard fires textupdate. Switch the system to an IME such as Japanese, Chinese or Korean, or use the fake-IME example on this page to see formats.

Is this the same as TextFormat or BodyFormat in Outlook?

No. In Outlook's object model, MailItem.BodyFormat takes an OlBodyFormat value such as olFormatHTML and decides whether an email body is Plain Text, Rich Text or HTML. The browser event on this page has nothing to do with sending mail.

Do I need EditContext for a normal textarea or contenteditable element?

No. For a textarea, an input or a contenteditable element, the browser already provides the text edit context. EditContext is for custom editable regions, such as text you draw on a canvas yourself.

Which browsers support it?

MDN lists the EditContext API as experimental with limited availability, meaning it is not Baseline. Check the compatibility table on MDN, and test at runtime with the feature check shown on this page.

Keep reading