[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-intl-segmenter-grapheme-word-sentence":44,"search-suggestions":60,"quiz-article-intl-segmenter-grapheme-word-sentence":106},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a02839-4855-71cc-8994-5719037e43c3","intl-segmenter-grapheme-word-sentence","PRACTICE_QUIZ","Intl.Segmenter and string truncation","Eight questions on why str.length lies about emoji, and how Intl.Segmenter counts what a reader actually sees.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,88,92,96,100,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",69,{"slug":66,"name":67,"articles":68},"frontend","Frontend",60,{"slug":70,"name":71,"articles":72},"javascript","Javascript",59,{"slug":74,"name":75,"articles":76},"css","Css",26,{"slug":78,"name":79,"articles":80},"typescript","Typescript",10,{"slug":82,"name":83,"articles":84},"performance","Performance",9,{"slug":86,"name":87,"articles":51},"tutorial","Tutorial",{"slug":89,"name":90,"articles":91},"grammar","Grammar",6,{"slug":93,"name":94,"articles":95},"react","React",5,{"slug":97,"name":98,"articles":99},"html","Html",4,{"slug":101,"name":102,"articles":99},"node","Node",{"slug":104,"name":105,"articles":99},"programming","Programming",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":95,"publishedAt":111,"viewCount":112,"likeCount":19,"commentCount":19,"author":113,"vertical":118,"topic":119,"tags":121,"_count":126,"playground":128,"body":130,"bodyMd":302,"seo":303,"translationGroupId":307,"series":52,"verticalId":5,"thread":308,"assessments":310,"translations":313,"quiz":315},"01a02839-480f-7428-914a-2c0442d00855","You're truncating bios with `.slice()`. `Intl.Segmenter` knows where the emoji actually end.","A 30-character bio limit that cuts off mid-emoji isn't a rendering bug — it's .length counting UTF-16 code units instead of what's on screen. Intl.Segmenter counts graphemes, words, and sentences the way a reader actually sees them, and every major browser supports it now.","\u002Fmedia\u002Fcovers\u002Fintl-segmenter-grapheme-word-sentence.png","2026-08-27T17:30:57.707Z",35,{"id":114,"name":115,"username":116,"avatarUrl":52,"headline":117},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":70,"name":120},"JavaScript",[122,123,124,125],{"slug":70,"name":71,"color":52},{"slug":62,"name":63,"color":52},{"slug":66,"name":67,"color":52},{"slug":86,"name":87,"color":52},{"assessments":127},1,{"slug":46,"title":129},"Intl.Segmenter — interactive playground",{"blocks":131,"version":127},[132,136,139,142,147,150,156,159,162,166,169,172,175,179,182,186,189,192,195,199,202,205,209,212,215,218,221,224,227,230,233,237,240,243,246,249,257,260,263,266,269,272,275,278,281,284,287,290,293,296],{"id":133,"html":134,"type":135},"b1","\u003Cp>Somebody fills in their bio: &quot;I love ☕ and my family 👨‍👩‍👧‍👦!&quot; Your limit is 30 characters. You call \u003Ccode>.slice(0, 30)\u003C\u002Fcode> before saving, same as you always do.\u003C\u002Fp>","paragraph",{"id":137,"html":138,"type":135},"b2","\u003Cp>What gets saved is \u003Ccode>&quot;I love ☕ and my family 👨‍👩&quot;\u003C\u002Fcode> — followed by a dangling zero-width joiner with nothing after it. The family of four just lost two kids. On some renderers you get a lone half-emoji instead: a little black diamond with a question mark where a face should be.\u003C\u002Fp>",{"id":140,"html":141,"type":135},"b3","\u003Cp>Nobody touched your CSS. The bug is \u003Ccode>.length\u003C\u002Fcode> — and it&#39;s lying to you about what a &quot;character&quot; is.\u003C\u002Fp>",{"id":143,"html":144,"text":145,"type":146,"level":31},"b4","What \u003Ccode>.length\u003C\u002Fcode> is actually counting","What .length is actually counting","heading",{"id":148,"html":149,"type":135},"b5","\u003Cp>Open a console and check what one emoji costs:\u003C\u002Fp>",{"id":151,"code":152,"type":153,"language":154,"highlight":155},"b6","\"😀\".length          \u002F\u002F 2\n[...\"😀\"].length      \u002F\u002F 1","code","js",[],{"id":157,"html":158,"type":135},"b7","\u003Cp>\u003Ccode>.length\u003C\u002Fcode> isn&#39;t counting characters. It&#39;s counting UTF-16 code units — the 16-bit chunks JavaScript strings are actually made of. Most emoji live outside the range a single 16-bit unit can address, so the engine stores them as a \u003Cstrong>surrogate pair\u003C\u002Fstrong>: two code units that only mean something together. \u003Ccode>😀\u003C\u002Fcode> is one character and two units. \u003Ccode>.slice()\u003C\u002Fcode>, \u003Ccode>.substring()\u003C\u002Fcode>, and \u003Ccode>charAt()\u003C\u002Fcode> all work in code units, with no idea that a pair belongs together.\u003C\u002Fp>",{"id":160,"html":161,"type":135},"b8","\u003Cp>Spreading the string looks like the fix, because it iterates by \u003Cem>code point\u003C\u002Fem> instead of code unit:\u003C\u002Fp>",{"id":163,"code":164,"type":153,"language":154,"highlight":165},"b9","[...\"😀\"].length      \u002F\u002F 1  ✓ fixed",[],{"id":167,"html":168,"type":135},"b10","\u003Cp>That patches the surrogate-pair problem. It does not patch the next one.\u003C\u002Fp>",{"id":170,"html":171,"text":171,"type":146,"level":31},"b11","Where the spread trick still breaks",{"id":173,"html":174,"type":135},"b12","\u003Cp>The family emoji isn&#39;t one code point wearing a costume — it&#39;s four separate people emoji glued together with an invisible joiner character (U+200D, zero-width joiner):\u003C\u002Fp>",{"id":176,"code":177,"type":153,"language":154,"highlight":178},"b13","const family = \"👨‍👩‍👧‍👦\";\n\nfamily.length                       \u002F\u002F 11  — code units\n[...family].length                  \u002F\u002F 7   — code points (4 people + 3 joiners)",[],{"id":180,"html":181,"type":135},"b14","\u003Cp>Seven. Not one. \u003Ccode>[...str]\u003C\u002Fcode> split the sequence right back apart into pieces that mean nothing on their own. A national flag has the same issue in miniature — it&#39;s two &quot;regional indicator&quot; letters standing in for a country code, one grapheme made of two code points:\u003C\u002Fp>",{"id":183,"code":184,"type":153,"language":154,"highlight":185},"b15","const flag = \"🇯🇵\";\nflag.length            \u002F\u002F 4\n[...flag].length       \u002F\u002F 2",[],{"id":187,"html":188,"type":135},"b16","\u003Cp>Every naive approach agrees the flag and the family are worth more than &quot;1.&quot; A reader looking at the screen would tell you they&#39;re each one thing.\u003C\u002Fp>",{"id":190,"html":191,"text":191,"type":146,"level":31},"b17","The fix: segment by what a reader sees",{"id":193,"html":194,"type":135},"b18","\u003Cp>\u003Ccode>Intl.Segmenter\u003C\u002Fcode> doesn&#39;t count units or code points. It counts \u003Cstrong>grapheme clusters\u003C\u002Fstrong> — the actual visual units a reader perceives as one character, using the same Unicode rules that render the emoji in the first place:\u003C\u002Fp>",{"id":196,"code":197,"type":153,"language":154,"highlight":198},"b19","const seg = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\n\n[...seg.segment(family)].length     \u002F\u002F 1  ✓\n[...seg.segment(flag)].length       \u002F\u002F 1  ✓",[],{"id":200,"html":201,"type":135},"b20","\u003Cp>One. One. That&#39;s what \u003Ccode>.length\u003C\u002Fcode> should have said all along.\u003C\u002Fp>",{"id":203,"html":204,"type":135},"b21","\u003Cp>Truncation gets the same fix — walk graphemes instead of code units, and the cut lands between characters instead of through one:\u003C\u002Fp>",{"id":206,"code":207,"type":153,"language":154,"highlight":208},"b22","function truncate(str, max) {\n  const seg = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\n  const graphemes = [...seg.segment(str)].map(s => s.segment);\n  return graphemes.slice(0, max).join(\"\");\n}\n\ntruncate(\"I love ☕ and my family 👨‍👩‍👧‍👦!\", 26)\n\u002F\u002F \"I love ☕ and my family 👨‍👩‍👧‍👦\" — the family survives, intact",[],{"id":210,"html":211,"type":135},"b23","\u003C!-- playground:start -->",{"id":213,"html":214,"text":214,"type":146,"level":31},"b24","🎮 Try it yourself",{"id":216,"html":217,"type":135},"b25","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fintl-segmenter-grapheme-word-sentence\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":219,"html":220,"type":135},"b26","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":222,"html":223,"type":135},"b27","\u003C!-- playground:end -->",{"id":225,"html":226,"type":135},"b28","\u003Cp>Paste your own string with an emoji sequence into it, drag the limit down, and watch \u003Ccode>.slice()\u003C\u002Fcode> mangle it while the grapheme-aware version doesn&#39;t.\u003C\u002Fp>",{"id":228,"html":229,"text":229,"type":146,"level":31},"b29","It does more than graphemes",{"id":231,"html":232,"type":135},"b30","\u003Cp>\u003Ccode>granularity\u003C\u002Fcode> also takes \u003Ccode>&quot;word&quot;\u003C\u002Fcode> and \u003Ccode>&quot;sentence&quot;\u003C\u002Fcode>, and both solve real problems \u003Ccode>.split(&quot; &quot;)\u003C\u002Fcode> can&#39;t:\u003C\u002Fp>",{"id":234,"code":235,"type":153,"language":154,"highlight":236},"b31","const words = new Intl.Segmenter(\"en\", { granularity: \"word\" });\n\nfor (const s of words.segment(\"Hello, world!\")) {\n  console.log(s.segment, \"→ wordLike:\", s.isWordLike);\n}\n\u002F\u002F \"Hello\" → wordLike: true\n\u002F\u002F \",\"     → wordLike: false\n\u002F\u002F \" \"     → wordLike: false\n\u002F\u002F \"world\" → wordLike: true\n\u002F\u002F \"!\"     → wordLike: false",[],{"id":238,"html":239,"type":135},"b32","\u003Cp>\u003Ccode>isWordLike\u003C\u002Fcode> is what makes this useful for a live word counter — filter to segments where it&#39;s \u003Ccode>true\u003C\u002Fcode> and punctuation stops inflating the count. And because it&#39;s \u003Ccode>Intl\u003C\u002Fcode>, pass a different locale and the rules change with it: word segmentation for \u003Ccode>&quot;ja&quot;\u003C\u002Fcode> finds boundaries in Japanese text with no spaces at all, something \u003Ccode>.split(&quot; &quot;)\u003C\u002Fcode> can never do regardless of locale.\u003C\u002Fp>",{"id":241,"html":242,"type":135},"b33","\u003Cp>Sentence segmentation exists too, but it&#39;s worth knowing where it&#39;s honest about its limits: it still trips on abbreviations like &quot;Dr.&quot; inside a sentence, splitting where a human wouldn&#39;t. Use it for rough chunking — a &quot;read more&quot; preview, a text-to-speech feed — not as a grammar-perfect sentence parser.\u003C\u002Fp>",{"id":244,"html":245,"text":245,"type":146,"level":31},"b34","Where this actually matters",{"id":247,"html":248,"type":135},"b35","\u003Cp>You don&#39;t need this for a codebase that only ever sees \u003Ccode>&quot;hello world&quot;\u003C\u002Fcode>. You need it the moment user-generated text meets:\u003C\u002Fp>",{"id":250,"type":251,"items":252,"ordered":18},"b36","list",[253,254,255,256],"\u003Cstrong>Character-limit inputs\u003C\u002Fstrong> — bios, tweet-style composers, SMS previews. Any limit enforced with \u003Ccode>.slice()\u003C\u002Fcode> can split a grapheme cluster and hand a broken half-emoji to the render pipeline.","\u003Cstrong>&quot;Read more&quot; truncation\u003C\u002Fstrong> — same bug, more visible, because it&#39;s live on every card in a feed.","\u003Cstrong>Client-side word\u002Fcharacter counters\u003C\u002Fstrong> — \u003Ccode>.split(&quot; &quot;).length\u003C\u002Fcode> overcounts on punctuation and undercounts on languages without spaces.","\u003Cstrong>Cursor movement in custom text widgets\u003C\u002Fstrong> — pressing &quot;left arrow&quot; once should skip one visual character, not land you inside a surrogate pair.",{"id":258,"html":259,"type":135},"b37","\u003Cp>None of these are exotic. They&#39;re the first bug report you get once your app has users outside a narrow slice of scripts and emoji usage.\u003C\u002Fp>",{"id":261,"html":262,"text":262,"type":146,"level":31},"b38","The part that makes this easy to adopt",{"id":264,"html":265,"type":135},"b39","\u003Cp>\u003Ccode>Intl.Segmenter\u003C\u002Fcode> shipped in Chrome and Edge in November 2020, Safari followed about five months later, and Firefox was the last of the three — it landed there in April 2024, which is the point the API officially became Baseline. There&#39;s no polyfill tax, no bundle-size argument against it — every browser your users are actually on has had it for a while now.\u003C\u002Fp>",{"id":267,"html":268,"type":135},"b40","\u003Cp>The fix for that bio field isn&#39;t a new dependency. It&#39;s swapping \u003Ccode>str.slice(0, n)\u003C\u002Fcode> for the grapheme-aware version above, three lines, and it stops being a bug the next person on your team has to rediscover.\u003C\u002Fp>",{"id":270,"html":271,"type":135},"b41","\u003Cp>So — does your character counter know the difference between a code unit and what&#39;s actually on the screen? Go paste an emoji into your own limit field and find out.\u003C\u002Fp>",{"id":273,"html":274,"type":135},"b42","\u003C!-- quiz:start -->",{"id":276,"html":277,"text":277,"type":146,"level":31},"b43","🧠 Test yourself",{"id":279,"html":280,"type":135},"b44","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fintl-segmenter-grapheme-word-sentence\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":282,"html":283,"type":135},"b45","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":285,"html":286,"type":135},"b46","\u003C!-- quiz:end -->",{"id":288,"type":289},"b47","divider",{"id":291,"html":292,"type":135},"b48","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":294,"html":295,"type":135},"b49","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":297,"type":251,"items":298,"ordered":18},"b50",[299,300,301],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","Somebody fills in their bio: \"I love ☕ and my family 👨‍👩‍👧‍👦!\" Your limit is 30 characters. You call `.slice(0, 30)` before saving, same as you always do.\n\nWhat gets saved is `\"I love ☕ and my family 👨‍👩\"` — followed by a dangling zero-width joiner with nothing after it. The family of four just lost two kids. On some renderers you get a lone half-emoji instead: a little black diamond with a question mark where a face should be.\n\nNobody touched your CSS. The bug is `.length` — and it's lying to you about what a \"character\" is.\n\n## What `.length` is actually counting\n\nOpen a console and check what one emoji costs:\n\n```js\n\"😀\".length          \u002F\u002F 2\n[...\"😀\"].length      \u002F\u002F 1\n```\n\n`.length` isn't counting characters. It's counting UTF-16 code units — the 16-bit chunks JavaScript strings are actually made of. Most emoji live outside the range a single 16-bit unit can address, so the engine stores them as a **surrogate pair**: two code units that only mean something together. `😀` is one character and two units. `.slice()`, `.substring()`, and `charAt()` all work in code units, with no idea that a pair belongs together.\n\nSpreading the string looks like the fix, because it iterates by *code point* instead of code unit:\n\n```js\n[...\"😀\"].length      \u002F\u002F 1  ✓ fixed\n```\n\nThat patches the surrogate-pair problem. It does not patch the next one.\n\n## Where the spread trick still breaks\n\nThe family emoji isn't one code point wearing a costume — it's four separate people emoji glued together with an invisible joiner character (U+200D, zero-width joiner):\n\n```js\nconst family = \"👨‍👩‍👧‍👦\";\n\nfamily.length                       \u002F\u002F 11  — code units\n[...family].length                  \u002F\u002F 7   — code points (4 people + 3 joiners)\n```\n\nSeven. Not one. `[...str]` split the sequence right back apart into pieces that mean nothing on their own. A national flag has the same issue in miniature — it's two \"regional indicator\" letters standing in for a country code, one grapheme made of two code points:\n\n```js\nconst flag = \"🇯🇵\";\nflag.length            \u002F\u002F 4\n[...flag].length       \u002F\u002F 2\n```\n\nEvery naive approach agrees the flag and the family are worth more than \"1.\" A reader looking at the screen would tell you they're each one thing.\n\n## The fix: segment by what a reader sees\n\n`Intl.Segmenter` doesn't count units or code points. It counts **grapheme clusters** — the actual visual units a reader perceives as one character, using the same Unicode rules that render the emoji in the first place:\n\n```js\nconst seg = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\n\n[...seg.segment(family)].length     \u002F\u002F 1  ✓\n[...seg.segment(flag)].length       \u002F\u002F 1  ✓\n```\n\nOne. One. That's what `.length` should have said all along.\n\nTruncation gets the same fix — walk graphemes instead of code units, and the cut lands between characters instead of through one:\n\n```js\nfunction truncate(str, max) {\n  const seg = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\n  const graphemes = [...seg.segment(str)].map(s => s.segment);\n  return graphemes.slice(0, max).join(\"\");\n}\n\ntruncate(\"I love ☕ and my family 👨‍👩‍👧‍👦!\", 26)\n\u002F\u002F \"I love ☕ and my family 👨‍👩‍👧‍👦\" — the family survives, intact\n```\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fintl-segmenter-grapheme-word-sentence\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\nPaste your own string with an emoji sequence into it, drag the limit down, and watch `.slice()` mangle it while the grapheme-aware version doesn't.\n\n## It does more than graphemes\n\n`granularity` also takes `\"word\"` and `\"sentence\"`, and both solve real problems `.split(\" \")` can't:\n\n```js\nconst words = new Intl.Segmenter(\"en\", { granularity: \"word\" });\n\nfor (const s of words.segment(\"Hello, world!\")) {\n  console.log(s.segment, \"→ wordLike:\", s.isWordLike);\n}\n\u002F\u002F \"Hello\" → wordLike: true\n\u002F\u002F \",\"     → wordLike: false\n\u002F\u002F \" \"     → wordLike: false\n\u002F\u002F \"world\" → wordLike: true\n\u002F\u002F \"!\"     → wordLike: false\n```\n\n`isWordLike` is what makes this useful for a live word counter — filter to segments where it's `true` and punctuation stops inflating the count. And because it's `Intl`, pass a different locale and the rules change with it: word segmentation for `\"ja\"` finds boundaries in Japanese text with no spaces at all, something `.split(\" \")` can never do regardless of locale.\n\nSentence segmentation exists too, but it's worth knowing where it's honest about its limits: it still trips on abbreviations like \"Dr.\" inside a sentence, splitting where a human wouldn't. Use it for rough chunking — a \"read more\" preview, a text-to-speech feed — not as a grammar-perfect sentence parser.\n\n## Where this actually matters\n\nYou don't need this for a codebase that only ever sees `\"hello world\"`. You need it the moment user-generated text meets:\n\n- **Character-limit inputs** — bios, tweet-style composers, SMS previews. Any limit enforced with `.slice()` can split a grapheme cluster and hand a broken half-emoji to the render pipeline.\n- **\"Read more\" truncation** — same bug, more visible, because it's live on every card in a feed.\n- **Client-side word\u002Fcharacter counters** — `.split(\" \").length` overcounts on punctuation and undercounts on languages without spaces.\n- **Cursor movement in custom text widgets** — pressing \"left arrow\" once should skip one visual character, not land you inside a surrogate pair.\n\nNone of these are exotic. They're the first bug report you get once your app has users outside a narrow slice of scripts and emoji usage.\n\n## The part that makes this easy to adopt\n\n`Intl.Segmenter` shipped in Chrome and Edge in November 2020, Safari followed about five months later, and Firefox was the last of the three — it landed there in April 2024, which is the point the API officially became Baseline. There's no polyfill tax, no bundle-size argument against it — every browser your users are actually on has had it for a while now.\n\nThe fix for that bio field isn't a new dependency. It's swapping `str.slice(0, n)` for the grapheme-aware version above, three lines, and it stops being a bug the next person on your team has to rediscover.\n\nSo — does your character counter know the difference between a code unit and what's actually on the screen? Go paste an emoji into your own limit field and find out.\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fintl-segmenter-grapheme-word-sentence\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":304,"canonical":305,"description":306},"You're truncating bios with `.slice()`. `Intl.Segmenter` knows where t","https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fintl-segmenter-grapheme-word-sentence","A 30-character bio limit that cuts off mid-emoji isn't a rendering bug — it's .length counting UTF-16 code units instead of what's on screen. Intl.Segmenter counts graphemes, words","01a02839-480f-7428-914a-32d41eff170e",{"id":309,"locked":18},"01a02839-4838-728a-b56e-2cd55ad9a3c6",[311],{"id":45,"slug":46,"title":48,"_count":312},{"questions":51},[314],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":316,"questionCount":51},{"questions":51}]