anyword API reference
Overview
anyword is a micro text segmenter built entirely on the native Intl.Segmenter API. Four functions, one options object, three granularities. Stable since 1.0 — the public API follows semver.
Naive JS quietly gets text wrong: .length miscounts emoji and accents, .split(" ") finds no words in Chinese or Thai, [...str] rips 👨👩👧👦 into pieces. The browser already knows where the real boundaries are. anyword is the thin wrapper — no rule tables, no locale files, no config.
import { anyword, anyword.count, anyword.truncate } from 'anyword'
anyword("don't stop 世界")
// ["don't", "stop", "世界"]
anyword("👨👩👧 hi", { by: 'grapheme' })
// ["👨👩👧", " ", "h", "i"]
anyword.count("世界 test")
// 2
anyword.truncate("héllo 👨👩👧", 5, { ellipsis: '…' })
// "héllo…" — never cuts an emoji in halfInstall
npm install anyword
# or
pnpm add anyword
# or
yarn add anywordZero dependencies, under 1kb gzipped, ESM and CJS builds with types. Also on JSR. Or take the whole family at once with npm install anyfamily.
anyword()
The main entry point. Pass text, optionally pass options. Returns the segments as plain strings, in order.
anyword(text)
anyword(text, options?)
anyword('hi there') // ["hi", "there"]
anyword('hi there', { by: 'grapheme' }) // ["h","i"," ","t","h","e","r","e"]
anyword("don't stop 世界") // ["don't", "stop", "世界"]
anyword('Hi. Go now!', { by: 'sentence' }) // ["Hi. ", "Go now!"]Word mode drops the segments between words — spaces and punctuation. Set raw: true to keep them, and the pieces join back into the original string.
anyword('hi, there!') // ["hi", "there"]
anyword('hi, there!', { raw: true }) // ["hi", ",", " ", "there", "!"]Migrating from 1.x
2.0 removed the separate anywordPartsand the other extra exports — they are the same functions and values, reached through the one name the package exports.
- import { anyword, anywordCount, anywordTruncate, supported } from 'anyword'
+ import { anyword } from 'anyword'
- anywordCount(text)
+ anyword.count(text)
- anywordTruncate(text, 20)
+ anyword.truncate(text, 20)
- supported ? anyword(text) : text.split(/\s+/)
+ anyword.supported ? anyword(text) : text.split(/\s+/)Arguments, return values and throwing behaviour are unchanged, and nothing else in the API moved. Every any* package follows this shape from 2.0 on: the bare call does the job, everything else hangs off the same name.
anyword.parts()
Same arguments as anyword(), but returns { segment, index, isWordLike? } instead of plain strings. The offsets point into the original text, so you can highlight or slice without searching again.
import { anyword } from 'anyword'
anyword.parts('世界 test')
// [
// { segment: '世界', index: 0, isWordLike: true },
// { segment: 'test', index: 3, isWordLike: true },
// ]
// React: highlight the matched word in place
anyword.parts(text, { raw: true }).map((p, i) =>
p.segment === query ? <mark key={i}>{p.segment}</mark> : p.segment,
)isWordLike is present in word mode only — in grapheme and sentence modes every segment is content.
anyword.count()
Takes the same options and counts segments instead of returning them.
anyword.count('世界 test') // 2
anyword.count('世界test') // 2 — .split(/\s+/) says 1
anyword.count('héllo', { by: 'grapheme' }) // 5
anyword.count('👨👩👧', { by: 'grapheme' }) // 1 — "👨👩👧".length is 8Grapheme counting is what a char-limit counter should show: the number of characters the user believes they typed.
anyword.truncate()
anyword.truncate(text, limit, options?) cuts to at most limit segments — graphemes by default, so an emoji or an accented letter is never split.
anyword.truncate('héllo 👨👩👧', 6) // "héllo "
anyword.truncate('héllo 👨👩👧', 5, { ellipsis: '…' }) // "héllo…"
anyword.truncate('one two three', 2, { by: 'word' }) // "one two "
anyword.truncate('short', 99) // "short" — already fitsThe cut lands on a segment boundary and keeps everything before it verbatim, trailing whitespace included. With ellipsis, that whitespace is trimmed and the ellipsis appended — and only when the text was actually too long, so short input comes back untouched. The ellipsis itself does not count toward limit.
Throws RangeError if limit is negative or not finite.
Granularity
by maps straight to Intl.Segmenter.
"word"words (default)"don't stop 世界" → ["don't", "stop", "世界"]"grapheme"user-perceived characters"👨👩👧 hi" → ["👨👩👧", " ", "h", "i"]"sentence"sentences"Hi. Go now!" → ["Hi. ", "Go now!"]Grapheme and sentence modes never drop anything, so raw does nothing there.
Options
by'word' | 'grapheme' | 'sentence'default: 'word'Segmentation unit. anyword.truncate defaults to 'grapheme' instead — cutting by character is what a length limit almost always means.
localestring | string[]default: runtime localeAny valid BCP 47 locale tag, or a fallback array — 'en', 'ja', 'th', ['xx-Nope', 'en'].
rawbooleandefault: falseWord mode only: keep the segments between words — spaces and punctuation. Ignored for grapheme and sentence, which never drop anything.
ellipsisstringdefault: ''anyword.truncate only. Appended when the text was actually cut; trailing whitespace is trimmed first. Does not count toward the limit.
What breaks without this
Every one of these is a bug report waiting to be filed by a user with an emoji in their name.
length counts code units, not characters
"👨👩👧".length is 8. A 280-character limit measured that way rejects a post that shows as 35 characters, and the number under the input is wrong for anyone writing outside ASCII.
slice cuts inside a character
Truncating by index splits a surrogate pair into two halves that render as a replacement glyph, or cuts a ZWJ sequence so a family emoji falls apart into three people. The cut has to land on a grapheme boundary.
split(' ') assumes spaces exist
Japanese, Chinese and Thai do not put them between words. A word count built on spaces returns 1 for an entire paragraph, and a preview truncated on spaces never truncates at all.
Regex word boundaries are ASCII rules
The \b word boundary splits don't into two words and treats accented letters inconsistently depending on the flags. Word segmentation is a Unicode algorithm, and the runtime already implements it.
Recipes
Copy, paste, move on.
// Word counter
anyword.count(post.body)
// 412
// Character counter users agree with (👨👩👧 counts as 1, not 8)
anyword.count(input, { by: 'grapheme' })
// Safe preview / char-limit cut
anyword.truncate(bio, 140, { ellipsis: '…' })
// Word-limited excerpt
anyword.truncate(article, 30, { by: 'word', ellipsis: ' …' })
// Per-character animation, emoji intact
anyword(title, { by: 'grapheme' }).map((c, i) => <span key={i}>{c}</span>)
// Safe reverse
anyword(text, { by: 'grapheme' }).reverse().join('')
// Initials
anyword(fullName).slice(0, 2)
.map((w) => anyword(w, { by: 'grapheme' })[0])
.join('')
// Split into sentences
anyword(text, { by: 'sentence' })React / Next.js
anyword is pure and synchronous, so it works in a component as-is. Whatanyfamily-react adds is a shared locale: set it once onAnyfamilyProvider and every hook below picks it up, so you do not thread locale through every call.
import { AnyfamilyProvider, useAnyword, useAnywordCount } from 'anyfamily-react'
function CharCounter({ value }: { value: string }) {
return <span>{useAnywordCount(value, { by: 'grapheme' })}/280</span>
}
// useAnyword returns an array, so it is memoized — the same segments keep the
// same reference until the text or the options change.
function Letters({ title }: { title: string }) {
return useAnyword(title, { by: 'grapheme' }).map((c, i) => <span key={i}>{c}</span>)
}`anywordSupported` is re-exported for feature-detecting `Intl.Segmenter`.
SSR
anyword is pure and synchronous — no clock, no state — so it renders the same on server and client. Pass a locale to keep output stable across the hydration boundary regardless of the runtime default.
import { anyword } from 'anyword'
export function CharCounter({ value }: { value: string }) {
return (
<span>{anyword.count(value, { by: 'grapheme', locale: 'en' })}/280</span>
)
}Locales
Pass any valid BCP 47 tag. Fallback arrays also work. The locale matters most for word breaking in scripts without spaces.
anyword('これは日本語です', { locale: 'ja' }) // ["これ", "は", "日本語", "です"]
anyword('สวัสดีชาวโลก', { locale: 'th' }) // ["สวัสดี", "ชาว", "โลก"] — no spaces needed
anyword("don't stop", { locale: 'en' }) // ["don't", "stop"]
anyword('hi', { locale: ['xx-Nope', 'en'] })When omitted, native Intl uses the runtime locale.
Support flag
Intl.Segmenter is missing on older runtimes. There anyword throws a clear error at call time; check the exported supported flag first if you target them.
import { anyword } from 'anyword'
anyword.supported ? anyword(text) : text.split(/\s+/)Through the anyfamily meta-package the same flag is exported as anywordSupported, since supportedcollides with anylong's.
vs the alternatives
What you would otherwise reach for, and what changes if you do.
| anyword | grapheme-splitter | words-count + lodash | |
|---|---|---|---|
| gzip | < 1kb | ~10kb | ~25kb |
| unicode data bundled | no | yes | yes |
| boundary rules | native Intl | bundled tables | regex |
| word / sentence mode | yes | grapheme only | spaces only |
| dependencies | 0 | 0 | 1+ |
anyword is not an NLP toolkit — it does one thing. Reach for a tokenizer or a full i18n framework when you need stemming, stop words or message catalogs.
Compatibility
anyword uses Intl.Segmenter — supported everywhere modern, and detectable via supported where it is not.
Limitations
A few things worth knowing before you ship:
Boundaries come from the runtime's ICU data
anyword delegates all segmentation to native Intl. Exact segment lists may vary between Node versions, browsers, and OSes — especially for CJK and Thai. Don't assert on exact arrays across environments; test behaviour, not strings.
Not an NLP toolkit
anyword does one thing: boundaries. No stemming, no stop words, no message catalogs, no tokenizer for model input. Reach for a real NLP library or i18n framework when you need those.
Missing on older runtimes
Intl.Segmenter landed late — Firefox 125, Safari 14.1. On engines without it every anyword function throws. Branch on the exported supported flag if you target them.
Word mode drops separators by default
anyword('hi, there!') returns two words — the comma and spaces are gone, so the pieces do not rejoin into the input. Pass raw: true when you need a lossless round trip.