IME & Composition

How Input Method Editors (IME) work with contenteditable and the challenges they present.

What is an IME?

An Input Method Editor (IME) is a software component that allows users to enter characters that are not directly available on their keyboard. Common examples include:

  • Japanese IME: Converts romanized input (romaji) into hiragana, katakana, or kanji
  • Korean IME: Combines individual jamo characters into complete syllables
  • Chinese IME: Converts pinyin or other phonetic input into Chinese characters
  • Vietnamese IME: Adds diacritical marks to base characters

Korean IME Variations

Korean IMEs can use different keyboard layouts and composition methods, which can affect how composition events fire and how text is composed:

Keyboard Layout Types

2벌식 (두벌식) - Most Common

  • Standard Korean keyboard layout
  • Most widely used in South Korea
  • Jamo characters arranged in two rows
  • Example: ㅎ (H), ㅏ (A), ㄴ (N) → 한

3벌식 (세벌식)

  • Alternative keyboard layout
  • Jamo characters arranged in three rows
  • Different key positions for same jamo
  • May affect composition event timing

390 자판

  • Another alternative layout
  • Less commonly used
  • May have different composition behavior

Composition Methods

조합형 (Composition-based)

  • Real-time composition: ㅎ + ㅏ + ㄴ → 한
  • Each jamo input triggers compositionupdate
  • Composition text changes with each keystroke
  • Most common method in modern Korean IMEs

완성형 (Completion-based)

  • User selects from pre-composed characters
  • May have different event patterns
  • Less common in modern IMEs

⚠️ Impact on Composition Events

Different keyboard layouts and composition methods can affect:

  • The number of compositionupdate events fired
  • The timing of when composition text is updated
  • How selection ranges change during composition
  • Whether beforeinput events fire for formatting shortcuts

Example: macOS Korean IME with 2벌식 may behave differently than Windows Korean IME with 3벌식, even for the same text input.

Composition events

When using an IME, the input process involves multiple stages tracked by composition events:

  1. compositionstart – Fires when the user starts composing (e.g., begins typing in Japanese)
  2. compositionupdate – Fires repeatedly as the composition changes (e.g., as candidate characters appear)
  3. compositionend – Fires when the composition is finalized (e.g., user selects a kanji)
element.addEventListener('compositionstart', () => {
  console.log('Composition started');
});

element.addEventListener('compositionupdate', (e) => {
  console.log('Composing:', e.data);
});

element.addEventListener('compositionend', (e) => {
  console.log('Composition ended:', e.data);
});

Composition Event Timing Variations

The exact timing and occurrence of compositionstart, compositionupdate, and compositionend events can vary significantly depending on multiple factors:

Factors Affecting Composition Event Timing

1. Keyboard/IME Type

  • Korean IME: 2벌식 vs 3벌식 vs 390 자판 may fire events at different points
  • Japanese IME: Different IME engines (Microsoft IME, Google IME, etc.) may have different timing
  • Chinese IME: Pinyin vs other input methods may trigger events differently
  • Mobile keyboards: Gboard, Samsung Keyboard, iOS QuickType all have different composition behaviors

2. Browser

  • Chrome/Edge: May fire compositionupdate before beforeinput in some cases
  • Firefox: Different event ordering and timing
  • Safari: May have unique composition event patterns
  • Event order can change between browser versions

3. Operating System

  • macOS: Uses system-level IME, may have different timing than Windows
  • Windows: IME behavior varies by Windows version and IME provider
  • Linux: IME implementation varies by distribution and desktop environment
  • Android: OS-level keyboard integration affects event timing
  • iOS: System keyboard behavior differs from desktop

4. Device Type

  • Desktop: Physical keyboard input may trigger events differently
  • Mobile: Virtual keyboard with text prediction may skip or delay events
  • Tablet: Hybrid behavior between desktop and mobile

Example: Korean IME Timing Differences

macOS + Chrome + Korean IME (2벌식)

Typing "한":
1. compositionstart (when "ㅎ" pressed)
2. compositionupdate (data: "ㅎ")
3. compositionupdate (data: "하") - when "ㅏ" pressed
4. compositionupdate (data: "한") - when "ㄴ" pressed
5. compositionend (when Space/Enter pressed)

Windows + Firefox + Korean IME (3벌식)

Typing "한":
1. compositionstart (when "ㅎ" pressed)
2. compositionupdate (data: "ㅎ")
3. compositionupdate (data: "하") - when "ㅏ" pressed
4. compositionupdate (data: "한") - when "ㄴ" pressed
5. compositionend (when Space/Enter pressed)
⚠️ May have different timing between updates

Android + Samsung Keyboard + Text Prediction

Typing "한":
1. compositionstart (may be delayed or skipped)
2. compositionupdate (data: "한") - may fire once with full text
3. compositionend (immediate or delayed)
⚠️ Events may be batched or skipped due to prediction

⚠️ Critical: Unpredictable Event Timing

Composition event timing is NOT guaranteed to be consistent:

  • compositionstart may fire before, after, or simultaneously with beforeinput
  • compositionupdate may fire multiple times per keystroke or skip keystrokes
  • compositionend may fire immediately after commit or be delayed
  • Some IMEs may not fire compositionstart at all in certain scenarios
  • Mobile keyboards with text prediction may skip composition events entirely

Best Practice: Never rely on exact event timing. Always check the isComposing flag and DOM state to determine composition status.

Detecting Composition State Reliably

Instead of relying on event timing, use these approaches:

  • Check isComposing flag: Available on beforeinput and input events
  • Track composition state: Set a flag on compositionstart, clear on compositionend
  • Compare DOM state: Check if composition text exists in DOM between events
  • Handle missing events: Don't assume compositionstart will always fire
  • Test across platforms: Test with different keyboards, browsers, and devices

⚠️ Important: Store targetRanges from beforeinput

When beforeinput and input have different inputType values:

  • The targetRanges from beforeinput are crucial for understanding actual DOM changes
  • targetRanges are only available in beforeinput events, not in input events
  • Always store targetRanges from beforeinput for use in input handlers
  • Compare inputType values between events to detect mismatches
// Store targetRanges from beforeinput
let lastBeforeInputTargetRanges = null;
let lastBeforeInputType = null;

element.addEventListener('beforeinput', (e) => {
  // Store targetRanges, inputType for use in input handler
  lastBeforeInputTargetRanges = e.getTargetRanges?.() || [];
  lastBeforeInputType = e.inputType;
});

element.addEventListener('input', (e) => {
  // Check for inputType mismatch
  if (lastBeforeInputType && e.inputType !== lastBeforeInputType) {
    // Use targetRanges from beforeinput to understand actual change
    if (lastBeforeInputTargetRanges && lastBeforeInputTargetRanges.length > 0) {
      // Process based on targetRanges rather than inputType
      handleActualChange(lastBeforeInputTargetRanges, e);
    }
  }
  
  // Clear stored values
  lastBeforeInputTargetRanges = null;
  lastBeforeInputType = null;
});

Selection During Composition

During IME composition, the selection behavior is unique: the selection range changes with each compositionupdate, but visually, only a single cursor is shown.

How Selection Works During Composition

1. Composition Text in DOM

During composition, the composition text is actually inserted into the DOM as text nodes. It's not just a visual overlay - it exists as real DOM content.

Example DOM during composition:
<p>Hello |</p>
The composition text "한" is a real text node in the DOM

2. Selection Range Changes

As the user types during composition, the selection range expands to include the composition text:

Selection range during composition:
Range: start at position 6, end at position 7
Selected text: "한"
The range includes the composition text

3. Visual Cursor vs Selection Range

Despite the selection range including the composition text, visually only one cursor is shown at the end of the composition text. The composition text itself is typically displayed with an underline or highlight to indicate it's temporary.

ℹ️ Browser Internal Management

Who manages the composition selection?

  • Browser: The browser manages the composition selection internally. It creates and updates a Range that includes the composition text.
  • IME/Keyboard: The IME (Input Method Editor) communicates with the browser to indicate where composition text should be placed, but the browser is responsible for managing the actual DOM and selection.
  • Selection API: window.getSelection() returns the current selection, which includes the composition text during composition.

The browser maintains a composition range that is separate from the user's visible selection, but is accessible through the Selection API.

Example: Selection Changes During Composition (2벌식 Korean IME)

User types "ㅎ" (first jamo)

Selection:
Range: start=6, end=7
Selected: "ㅎ"
Visual: cursor after "ㅎ"
compositionupdate fires with data: "ㅎ"

User types "ㅏ" (second jamo) → "하"

Selection:
Range: start=6, end=7
Selected: "하"
Visual: cursor after "하" (same position, different text)
compositionupdate fires with data: "하" (previous "ㅎ" replaced)

User types "ㄴ" (third jamo) → "한"

Selection:
Range: start=6, end=7
Selected: "한"
Visual: cursor after "한" (same position, different text)
compositionupdate fires with data: "한" (previous "하" replaced)

User commits (Space/Enter) → Composition ends

Selection:
Range: start=7, end=7 (collapsed)
Selected: ""
Visual: cursor after "한" (now committed text)
compositionend fires, text "한" is committed to DOM

ℹ️ Note: Keyboard Layout Differences

Different Korean keyboard layouts (2벌식, 3벌식, 390) may behave differently:

  • The number of compositionupdate events may vary
  • The composition text at each step may differ
  • Selection range behavior may be slightly different
  • Event timing may vary between layouts

Always test with different keyboard layouts if your application needs to support various Korean input methods.

⚠️ Important for Editors

When editors use preventDefault() and manipulate DOM:

  • The browser's composition selection may become invalid
  • If you re-render DOM from your internal model, the composition text may be lost
  • The browser's composition range may not match your model's state
  • You may need to manually restore the selection after DOM updates

Best Practice: During composition, avoid programmatic DOM updates that would interfere with the browser's composition management. Let the browser handle composition text insertion, then sync with your model after compositionend.

Common issues

Enter key during composition

Pressing Enter during IME composition may cancel the composition instead of committing it. This breaks the expected workflow for users.

See cases: scenario-ime-enter-breaks

Backspace granularity

Backspace may delete entire composed syllables instead of individual characters, making fine-grained editing difficult.

See cases: scenario-ime-backspace-granularity

Space key during composition

The Space key may be ignored or commit composition inconsistently during IME input, depending on the browser and OS.

See cases: scenario-space-during-composition

Undo during composition

Undo operations during active composition may clear more text than expected, including text that was already committed.

See cases: scenario-undo-with-composition

Duplicate keydown events with keyCode 229

During IME composition, pressing certain keys (especially Enter) may trigger duplicate keydown events. The first event has keyCode: 229 (indicating IME is processing the input), followed by the actual key's keyCode (e.g., 13 for Enter). This can cause event handlers to execute twice for a single key press.

See cases: scenario-ime-composition-keydown-keycode-229

beforeinput and input inputType mismatch

During IME composition, the beforeinput event may have a different inputType than the corresponding input event. For example, beforeinput may fire with insertCompositionText while input fires with deleteContentBackward. This requires storing beforeinput's targetRanges for use in input event handling.

See cases: scenario-beforeinput-input-inputtype-mismatch

Mobile Keyboards & Text Prediction

Mobile keyboards differ significantly from desktop keyboards, especially when text prediction/suggestion features are enabled. Different keyboard apps (Gboard, Samsung Keyboard, iOS QuickType, etc.) may behave differently.

Mobile Keyboard Types

Android Keyboards

  • Gboard (Google Keyboard): Google's keyboard with text prediction
  • Samsung Keyboard: Samsung's default keyboard with phrase suggestion
  • SwiftKey: Third-party keyboard with advanced prediction
  • Each may use different inputType values and event patterns

iOS Keyboards

  • iOS QuickType: Apple's predictive text feature
  • Third-party keyboards: Various keyboards available in App Store
  • Generally more predictable than Android keyboards

⚠️ Text Prediction/Suggestion Features

When text prediction is enabled:

  • beforeinput may not fire before suggestion insertion
  • Multiple input events may fire for a single suggestion selection
  • Suggested text may replace more content than expected (entire words/phrases)
  • Event timing differs significantly from manual typing
  • Selection ranges in beforeinput and input may differ

Impact: Text prediction can interfere with custom editing logic, undo/redo stacks, and change tracking systems.

Selection Differences: beforeinput vs input

Why Selection Differs

beforeinput Event

  • Fires before DOM changes occur
  • Selection range reflects the current state (before text insertion/replacement)
  • May show the range that will be replaced by the suggestion
  • In text prediction scenarios, may not fire at all

input Event

  • Fires after DOM changes occur
  • Selection range reflects the new state (after text insertion/replacement)
  • Shows cursor position after the inserted/replaced text
  • May fire multiple times for a single suggestion (bulk insertion)

Example: Text Prediction with Selection Differences

Scenario: User types "안녕", keyboard suggests "안녕하세요"

Before suggestion selection:
DOM: <p>안녕|</p>
Selection: start=2, end=2 (collapsed)

User selects suggestion "안녕하세요"

beforeinput event (if it fires):
Selection: start=0, end=2 (may include "안녕" to be replaced)
inputType: "insertReplacementText" or "insertFromPredictiveText"
data: "안녕하세요"
⚠️ Note: beforeinput may not fire at all!
input event (after DOM change):
DOM: <p>안녕하세요|</p>
Selection: start=5, end=5 (collapsed, after "안녕하세요")
inputType: "insertReplacementText" or "insertFromPredictiveText"
data: "안녕하세요"
Selection now reflects the new state

⚠️ Critical: Selection Mismatch

The selection in beforeinput and input events can be completely different:

  • beforeinput selection shows what will be replaced
  • input selection shows the final cursor position
  • If beforeinput doesn't fire, you only see the input selection
  • Multiple input events may have different selections as text is inserted in chunks

Best Practice: Always compare DOM state before and after events to understand what actually changed, rather than relying solely on selection ranges.

Handling Mobile Text Prediction

  • Monitor both events: Listen to both beforeinput and input to catch all text insertions
  • Compare DOM states: Store DOM state before events and compare after to detect bulk insertions
  • Don't rely on selection alone: Selection ranges may not accurately reflect what was inserted/replaced
  • Handle multiple input events: A single suggestion may trigger multiple input events
  • Consider disabling prediction: Use autocomplete="off" or spellcheck="false" if it interferes with your use case

Browser differences

Event timing: The timing and order of composition events varies significantly between browsers, especially when special keys (Enter, Backspace) are pressed during composition.

IME candidate window: The position and behavior of the IME candidate window (showing possible character conversions) differs across browsers and may not align correctly with the caret.

Composition events missing: Some IMEs may not fire composition events consistently, or events may fire in unexpected orders, making it difficult to track the composition state.

Selection range behavior: How the selection range is managed during composition may differ between browsers. Some browsers may show the composition text as selected, while others only show a cursor.

Composition Selection & Range Management

Technical Details: How Composition Selection Works

Browser's Internal Composition Range

The browser maintains a composition range that is separate from the user's visible selection:

  • During composition, the browser creates a Range that includes the composition text
  • This range is updated on each compositionupdate event
  • The Range's start and end positions change as composition text changes
  • The Range is accessible via window.getSelection().getRangeAt(0)

Visual vs Actual Selection

Even though the selection range includes the composition text (making it a non-collapsed selection), the browser renders it as a single cursor:

  • The composition text is visually highlighted/underlined to indicate it's temporary
  • The cursor appears at the end of the composition text
  • The selection range spans the entire composition text, but this is not visually shown as a selection

Why Selection Changes But Cursor Doesn't

The selection range changes because:

  • Each compositionupdate replaces the previous composition text with new text
  • The Range's boundaries are updated to match the new composition text
  • The Range's toString() returns the current composition text
  • But visually, the browser only shows a cursor (not a selection highlight) because it's composition text

Code Example: Observing Selection During Composition

// Monitor selection changes during composition
element.addEventListener('compositionupdate', (e) => {
  const selection = window.getSelection();
  if (selection && selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    console.log('Composition text:', e.data);
    console.log('Selection range:', {
      start: range.startOffset,
      end: range.endOffset,
      text: range.toString(),
      collapsed: range.collapsed
    });
    // Note: range.toString() returns the composition text
    // range.collapsed may be false even though cursor looks like one position
  }
});

// Compare with compositionend
element.addEventListener('compositionend', (e) => {
  const selection = window.getSelection();
  if (selection && selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    console.log('After composition:', {
      start: range.startOffset,
      end: range.endOffset,
      text: range.toString(),
      collapsed: range.collapsed
    });
    // Now range is typically collapsed at the end of committed text
  }
});

Related resources