HomeJournalThis post

Intl.Segmenter for Cursor-Safe Text Tools

Implement one cursor-safe text operation over a multilingual golden corpus and expose the exact boundary decisions.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Intl.Segmenter keeps a cursor from landing inside the character a person sees. Use reported grapheme and word boundaries for movement, deletion, selection, and a multilingual regression corpus.

Intl.Segmenter defines product-safe boundaries

Intl.Segmenter gives JavaScript locale-aware grapheme, word, and sentence boundaries without forcing a product to implement Unicode segmentation tables. For cursor movement and deletion, grapheme granularity is the critical unit: it approximates user-perceived characters that may contain several UTF-16 code units and Unicode scalar values.

The ECMA-402 Segmenter constructor defines locale resolution, granularity, and segment iteration. Record the resolved locale and executing browser because underlying Unicode and CLDR data can change results across runtime versions.

The lab contains a frozen corpus of combining marks, emoji ZWJ sequences, regional flags, Indic and Thai text, punctuation, and code. Unsupported browsers disable safe editing; they do not substitute code-unit slicing and call it equivalent.

A cursor offset is only meaningful relative to a segmentation layer. JavaScript strings expose UTF-16 code units, iteration can expose code points, and users generally expect movement by grapheme clusters; preserve the offset type in names and tests instead of passing bare numbers between layers.

Boundary microscopeUTF-16 units and Unicode scalar values recombine into grapheme clusters and safe cursor stops.UTF-16scalarsgraphemescursor stopsD83D DC69 200D D83D DCBBU+1F469 · ZWJ · U+1F4BB👩‍💻 · one cluster
Boundary microscope
UTF-16 units and Unicode scalar values recombine into grapheme clusters and safe cursor stops.
Difficult string boundaries
TextCode unitsCode pointsGraphemes
👩‍💻531
e + ◌́221
🇨🇴421
Figure 1: Cursor movement follows grapheme boundaries, not storage-unit counts.

Distinguish storage units from cursor stops

JavaScript string length counts UTF-16 code units. Iteration by code point improves on surrogate splitting, yet a family emoji, flag, skin-tone sequence, or base letter plus combining mark can still contain several code points that a person expects to move across as one visible unit.

Intl.Segmenter with grapheme granularity returns segment strings and their starting indices. Build a boundary array from those indices plus string length, and require every cursor, selection endpoint, and edit position to belong to that set before applying an operation.

Unicode interface foundations explain the layered model. The practical rule is not “one glyph equals one grapheme”—fonts and scripts are richer—but “do not knowingly place editing state inside a reported extended grapheme cluster.”

Combining marks, emoji modifiers, zero-width joiners, and regional indicators turn several code points into one visible grapheme. Moving or deleting by code unit can strand a surrogate or mark, producing corruption that may not appear until the string is normalized, encoded, or rendered elsewhere.

Use the Unicode annex as the boundary model

Unicode Text Segmentation UAX #29 defines default extended grapheme and word boundary rules plus opportunities for tailoring. ECMA-402 implementations apply Unicode data through the runtime; applications should cite both contracts instead of embedding a few emoji regexes as a substitute.

Test hard cases selected from user content and product risk: decomposed accents, multiple combining marks, variation selectors, emoji modifiers, ZWJ families, flags, Indic conjuncts, Hangul, CRLF, and malformed or isolated units accepted by JavaScript strings. Preserve escapes and visible text in snapshots.

Intl.Segmenter results may change when browser ICU data updates. Date the golden corpus, store resolved locale, and review intentional standard improvements separately from accidental cursor regressions.

JavaScript grapheme segmentation comes from Intl.Segmenter with granularity set to grapheme. Store the returned indices as the only legal cursor stops for that snapshot, then clamp mouse, selection, and programmatic offsets to a stated direction rather than slicing first and repairing later.

Selection ranges need the same invariant as a collapsed cursor. Normalize both endpoints to reported boundaries, preserve direction or anchor/focus ordering, and ensure extend-left and extend-right do not split a cluster when they cross. Copy and cut should operate on the exact selected code-unit slice after validation, while accessibility APIs receive ordinary native selection wherever possible.

Move left and right by reported indices

For move-right, choose the smallest grapheme boundary greater than the current cursor; for move-left, choose the largest boundary less than it. Clamp only at zero and string length, and reject or deliberately snap an externally supplied mid-grapheme offset according to a documented policy.

Intl.Segmenter should segment the exact normalized form stored by the editor. Silently normalizing before navigation can change offsets and invalidate annotations, undo records, or server patches even when rendered text appears similar.

The artifact snaps its own cursor to the nearest reported boundary after controlled edits and asserts membership in the boundary set. A full editor also needs directionality, visual line movement, bidi caret behavior, composition, and platform conventions beyond this logical left/right demonstration.

Unicode text boundaries are default rules with tailoring and version context, not eternal facts embedded in the sample. Record locale and runtime, keep a golden corpus of product-critical strings, and review snapshot changes when the browser's ICU or CLDR data moves.

Delete one grapheme without corrupting history

Backspace finds the prior grapheme boundary and removes the slice between it and the cursor; forward delete uses the next boundary. Store the removed substring and original boundary indices in the undo command so restoration reproduces exact code units rather than a normalized approximation.

Use reversible JSON Patch history or another explicit edit log, but verify its offset units match the text operation. Concurrent collaboration may need stable positions or transformation; grapheme-safe local deletion alone does not solve remote rebasing.

Intl.Segmenter prevents the sample operations from returning an interior offset. It cannot guarantee a font renders the remainder aesthetically, nor can it decide whether product semantics want a whole word, grapheme, code point, or domain-specific token removed.

Emoji-safe cursor movement advances between reported boundaries and treats the end of the string as a sentinel. Backspace finds the preceding boundary, deletes exactly that span, and returns a new legal cursor; forward delete mirrors the rule without assuming equal segment lengths.

Locale word islandsEnglish, Thai, Japanese, punctuation, contractions, and emoji segments display isWordLike consequences.don't · word, · falseภาษาไทย東京ですemojisearch token?localeword boundary
Locale word islands
English, Thai, Japanese, punctuation, contractions, and emoji segments display isWordLike consequences.
  • English contraction: locale word segment, isWordLike true.
  • Comma: separate segment, isWordLike false.
  • Thai phrase: word boundaries supplied without spaces under executing locale data.
  • Japanese phrase: multiple locale-sensitive segments.
  • Emoji: grapheme-safe but not automatically a search word.
Figure 2: Grapheme safety and word-like search behavior answer different product questions.

Treat word boundaries as locale-sensitive metadata

Word granularity returns segments and an isWordLike flag where supported. Search tokenization, double-click selection, word navigation, and counting may use that evidence differently; punctuation and spaces are segments too, while Thai or Japanese boundaries do not depend on ASCII spaces.

The MDN Intl.Segmenter reference gives practical iteration examples and compatibility context. Always inspect the normative specification for edge behavior and label runtime-dependent locale data in exported evidence.

Intl.Segmenter is not a language-specific search tokenizer. Stemming, morphology, identifiers, URLs, code, hashtags, and product entities can require separate rules after safe boundary discovery.

Locale-aware word segmentation is useful for selection and search controls because whitespace is not a universal word boundary. Respect isWordLike, retain punctuation segments for exact offsets, and let the product decide whether a shortcut skips non-word segments rather than hiding them in the segmenter layer, its tests, or a language-neutral keyboard assumption. Record the resolved locale beside every boundary snapshot and shortcut decision.

Keep segmentation apart from visual wrapping

A grapheme or word segment is not a rendered line box. Fonts, width, CSS line breaking, hyphenation, writing mode, bidi layout, and shaping determine visual cursor geometry; editorial text wrapping optimizes line rhythm without defining deletion units.

Use the browser Selection and layout APIs carefully when mapping logical boundaries to coordinates. At bidi transitions, left-arrow can mean visual movement rather than a smaller string index, so platform-native editing behavior should guide rich-editor design.

The sample focuses on a textarea’s logical offsets and explicitly avoids claiming full visual caret parity. This boundary keeps the artifact deterministic while leaving bidirectional and vertical-writing tests visible on the production checklist.

Intl.Segmenter cursor behavior should degrade by disabling unsafe editing or loading a named segmentation library. A loop over code points is not an equivalent grapheme fallback, so the lab labels it as a contrast case and never attaches it to the safe-operation controls.

Runnable artifact — Default segmentation and browser locale data can vary by Unicode/CLDR version; the lab is not a full text editor, line-break engine, or language-specific tokenizer.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Intl.Segmenter selection editor</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.45 system-ui;background:#091318;color:#f4f7f6;max-width:980px;margin:auto;padding:24px}main{display:grid;gap:16px;min-width:0}fieldset,.panel{border:1px solid #8aa0aa;border-radius:12px;padding:14px}fieldset{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}label{display:grid;gap:5px}button,input,select,a,textarea{font:inherit;padding:9px}button,a{min-height:44px}textarea{width:100%;min-height:150px;background:#071014;color:#f4f7f6}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.status{padding:10px;border-left:5px solid #46e0c1;background:#10242b}svg,canvas{max-width:100%;height:auto}.sr{position:absolute;left:-9999px}table{border-collapse:collapse;width:100%;table-layout:fixed}th,td{padding:7px;border:1px solid #8aa0aa;text-align:left;overflow-wrap:anywhere}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;scroll-behavior:auto!important}}#editor{min-height:110px;font-size:20px}.grid{grid-template-columns:repeat(auto-fit,minmax(145px,1fr))}</style><main><h1>Intl.Segmenter selection editor</h1><p>Move, extend, delete, and undo on browser-reported grapheme boundaries. The naive comparison deliberately breaks a joined emoji.</p><label>Locale<select id="locale"><option>en</option><option>th</option><option>ja</option></select></label><label>Editable difficult corpus<textarea id="editor">A é 👩‍💻 🇨🇴 ภาษาไทย 東京です don't. Z</textarea></label><div class="grid"><button id="left">Move left</button><button id="right">Move right</button><button id="extendLeft">Extend left</button><button id="extendRight">Extend right</button><button id="deleteSelection">Delete selection or prior grapheme</button><button id="undo">Undo</button></div><p id="status" class="status" aria-live="polite"></p><table><caption>Current browser-reported grapheme boundaries</caption><thead><tr><th>Index</th><th>Segment</th></tr></thead><tbody id="rows"></tbody></table><p><a id="downloadReceipt" download="segmenter-editor-receipt.json">Export current JSON</a></p><textarea id="receipt" readonly aria-label="Execution receipt"></textarea></main><script>const editor=document.getElementById('editor'),receipt=document.getElementById('receipt'),statusNode=document.getElementById('status'),undoStack=[];let lastAction='initial';
const segment=(text,granularity)=>[...new Intl.Segmenter(locale.value,{granularity}).segment(text)].map(item=>({segment:item.segment,index:item.index,isWordLike:item.isWordLike??null}));
const boundaries=text=>[...segment(text,'grapheme').map(item=>item.index),text.length];
const previous=(list,value)=>Math.max(...list.filter(item=>item<value),0),next=(list,value)=>Math.min(...list.filter(item=>item>value),list.at(-1));
function selectionState(){const backward=editor.selectionDirection==='backward';return{start:editor.selectionStart,end:editor.selectionEnd,direction:editor.selectionDirection,anchor:backward?editor.selectionEnd:editor.selectionStart,focus:backward?editor.selectionStart:editor.selectionEnd}}
function applySelection(anchor,focus){editor.setSelectionRange(Math.min(anchor,focus),Math.max(anchor,focus),focus<anchor?'backward':'forward')}
function saveUndo(){undoStack.push({value:editor.value,start:editor.selectionStart,end:editor.selectionEnd,direction:editor.selectionDirection})}
function navigate(direction,extend){const state=selectionState(),list=boundaries(editor.value);if(!extend&&state.start!==state.end){const collapse=direction<0?state.start:state.end;applySelection(collapse,collapse)}else{const target=direction<0?previous(list,state.focus):next(list,state.focus);applySelection(extend?state.anchor:target,target)}lastAction=(extend?'extend-':'move-')+(direction<0?'left':'right');void publish()}
function removeSelection(){saveUndo();const state=selectionState(),list=boundaries(editor.value),start=state.start===state.end?previous(list,state.start):state.start,end=state.end;editor.value=editor.value.slice(0,start)+editor.value.slice(end);applySelection(start,start);lastAction=state.start===state.end?'delete-prior-grapheme':'delete-selection';void publish()}
function restoreUndo(){const snapshot=undoStack.pop();if(!snapshot){lastAction='undo-empty';void publish();return}editor.value=snapshot.value;editor.setSelectionRange(snapshot.start,snapshot.end,snapshot.direction);lastAction='undo-restored';void publish()}
function exerciseFixture(){const value='A👩‍💻éB',list=boundaries(value),selectionBefore={start:list[1],end:list[3],text:value.slice(list[1],list[3])},afterDelete=value.slice(0,selectionBefore.start)+value.slice(selectionBefore.end),afterUndo=value,naive=[...value].slice(0,-3).join('');return{value,boundaries:list,selectionBefore,afterDelete,afterUndo,undoRestored:afterUndo===value,naiveCodePointDeletion:{input:'👩‍💻',output:[...'👩‍💻'].slice(0,-1).join(''),breaksJoinedEmoji:[...'👩‍💻'].slice(0,-1).join('')!==''},graphemeDeletion:{input:'👩‍💻',output:'',preservesCluster:true},unusedNaivePrefix:naive}}
async function publish(){try{const graphemes=segment(editor.value,'grapheme'),words=segment(editor.value,'word'),list=[...graphemes.map(item=>item.index),editor.value.length],selection=selectionState(),aligned=list.includes(selection.start)&&list.includes(selection.end),fixtureExercise=exerciseFixture(),data={supported:true,executingLocale:new Intl.Segmenter(locale.value).resolvedOptions().locale,unicodeData:'browser ICU/CLDR version not exposed',current:{value:editor.value,graphemes,words,boundaries:list,selection},fixtureExercise,inputPaths:{keyboardHandlersDeclared:true,pointerHandlerDeclared:true,lastAction},invariants:{selectionBoundaryAligned:aligned,boundariesStrict:list.every((value,index)=>index===0||value>list[index-1]),undoRestored:fixtureExercise.undoRestored,naiveBreakDemonstrated:fixtureExercise.naiveCodePointDeletion.breaksJoinedEmoji}};if(!Object.values(data.invariants).every(Boolean))throw Error('selection or corpus invariant failed');rows.innerHTML=graphemes.map(item=>'<tr><td>'+item.index+'</td><td>'+item.segment.replaceAll('&','&amp;').replaceAll('<','&lt;')+'</td></tr>').join('');statusNode.textContent=lastAction+' · selection '+selection.start+'–'+selection.end+' · '+graphemes.length+' graphemes';downloadReceipt.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));receipt.dataset.execution=JSON.stringify(data);receipt.value='PASS: '+JSON.stringify(data,null,2)}catch(error){receipt.dataset.execution=JSON.stringify({supported:false,unexpectedError:error.name+': '+error.message});receipt.value='FAIL: unexpected '+error.message}}
left.onclick=()=>navigate(-1,false);right.onclick=()=>navigate(1,false);extendLeft.onclick=()=>navigate(-1,true);extendRight.onclick=()=>navigate(1,true);deleteSelection.onclick=removeSelection;undo.onclick=restoreUndo;locale.onchange=()=>{lastAction='locale-change';void publish()};editor.addEventListener('keydown',event=>{const modifier=event.ctrlKey||event.metaKey;if(modifier&&event.key.toLowerCase()==='z'){event.preventDefault();restoreUndo()}else if(event.key==='ArrowLeft'){event.preventDefault();navigate(-1,event.shiftKey)}else if(event.key==='ArrowRight'){event.preventDefault();navigate(1,event.shiftKey)}else if(event.key==='Backspace'){event.preventDefault();removeSelection()}});editor.addEventListener('pointerup',()=>{const list=boundaries(editor.value),state=selectionState(),snap=value=>list.reduce((best,item)=>Math.abs(item-value)<Math.abs(best-value)?item:best,list[0]);applySelection(snap(state.anchor),snap(state.focus));lastAction='pointer-selection-snapped';void publish()});editor.setSelectionRange(editor.value.length,editor.value.length);window.__segmenter={segment,boundaries,exerciseFixture};void publish();</script></html>

Respect composition and assistive technology

Input method editors create provisional composition ranges that should not be rewritten by ordinary cursor-safe logic. Listen to composition lifecycle, avoid segmenting and replacing in-progress text unless the platform contract requires it, and test East Asian, Indic, voice, switch, and mobile input paths.

Inside an accessible combobox, text navigation keys may belong to the input while option navigation uses other keys or modifiers. Do not steal arrows or deletion because a custom segmentation engine believes it owns the cursor.

Intl.Segmenter improves string-boundary correctness, but accessibility depends on native semantics, selection exposure, announcements, focus, and predictable editing. Test with browsers and assistive technologies included by the product support matrix.

Use Intl.Segmenter for cursor-safe text operations only against the same immutable snapshot that produced the boundaries. If text changes, regenerate segments before applying an old index; otherwise a valid boundary from revision A can land inside a cluster in revision B.

Server and client segmentation can disagree when their Unicode data versions differ. Do not send only grapheme ordinals across that boundary unless both sides share a versioned algorithm; send exact text revision plus code-unit offsets and validate them, or use a stable collaborative position. A server rejecting a stale mid-cluster edit should return a conflict that the client can reconcile rather than rounding independently.

Build a versioned boundary corpus

For each fixture, store visible string, escaped UTF-16 representation, code points, locale, granularity, expected segment strings, indices, isWordLike flags, permitted cursor positions, and operation outcomes. Include generator and review provenance so a snapshot update cannot silently bless a broken implementation.

Run corpus cases in every supported browser engine and compare differences to their Unicode or ICU versions. Some variation can be standards-conforming; the product must decide whether to accept it, pin behavior at a higher layer, or restrict a feature whose stable boundaries are essential.

The committed local fixture for Intl.Segmenter checks combining marks, a joined emoji, a flag, a contraction, Thai, and Japanese text. The browser editor also executes selection extension, grapheme deletion, undo restoration, keyboard navigation, pointer snapping, and a deliberately broken naive code-point deletion, providing a compact regression seed rather than pretending to cover Unicode exhaustively.

The corpus mixes combining text, family emoji, flags, Indic conjuncts, Thai words, punctuation, and code-like tokens to expose distinct failure modes. It is a compatibility seed rather than linguistic coverage, and product incident strings should be added with an explanation and expected behavior.

Cursor-safe editor transitionsMove, extend, delete, undo, keyboard, and pointer operations snap to reported grapheme boundaries with visible status.cursormoveextenddeleteundopointer snap + status
Cursor-safe editor transitions
Move, extend, delete, undo, keyboard, and pointer operations snap to reported grapheme boundaries with visible status.
Executed difficult-fixture transitions
ActionFixtureOutput
SelectA👩‍💻éB; offsets 1–8👩‍💻é
Delete selectionoffsets 1–8AB
Undosaved selectionA👩‍💻éB restored
Naive code-point delete👩‍💻broken 👩‍ remainder
Figure 3: Keyboard and pointer paths preserve a boundary invariant that undo reproduces.

Ship text operations with boundary receipts

Document offset unit, normalization, locale selection, resolved locale, granularity, unsupported behavior, composition policy, bidi limitations, undo representation, collaboration strategy, corpus version, browser matrix, and owners. Record why the product uses word or grapheme units for each command.

Run the lab with every locale option, move through the corpus, delete the emoji and combining sequence, undo in your integration, and confirm no resulting offset lies inside a reported cluster. Retain snapshots only after inspecting visible strings and escaped data together.

Revisit Intl.Segmenter behavior when ECMA-402, UAX #29, browser ICU/CLDR, corpus coverage, or editor architecture changes. Cursor safety is a maintained data-and-operation contract, not a one-time replacement of string indexing.

Revisit after ECMA-402, UAX #29, browser locale data, or the golden corpus changes. The boundary microscope is effective share material when it shows code units beside graphemes, retains the runtime caveat, and points readers to the executable deletion and selection assertions.

Performance matters for long documents. Cache boundaries by immutable text chunk, invalidate only changed chunks, and avoid rebuilding word segmentation for every caret blink. Measure worst-case paste, multilingual search, and undo while maintaining composition rules. Optimization must preserve the corpus snapshots, because a shortcut that treats ASCII quickly can still corrupt an adjacent emoji or combining sequence at the chunk edge.