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 half

Install

npm install anyword
# or
pnpm add anyword
# or
yarn add anyword

Zero 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 8

Grapheme 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 fits

The 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 locale

Any valid BCP 47 locale tag, or a fallback array — 'en', 'ja', 'th', ['xx-Nope', 'en'].

rawbooleandefault: false

Word 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.

anywordgrapheme-splitterwords-count + lodash
gzip< 1kb~10kb~25kb
unicode data bundlednoyesyes
boundary rulesnative Intlbundled tablesregex
word / sentence modeyesgrapheme onlyspaces only
dependencies001+

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.

Node.js18+CI runs the suite on Node 20, 22, 24
Chrome87+
Firefox125+
Safari14.1+
Vercel Edge Runtime
Cloudflare Workers
Deno

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.