twitter-text

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

2posts · 30d
Nov 50 posts
  1. string.length is wrong for X and Bluesky. My counter failed 8 of 34 tests against X's own library I'm Alex. I build Fuxux, a social media scheduler: you write a post once and it goes out to X, Bluesky, Instagram, LinkedIn, Threads and a few more. A scheduler has to answer one boring question before anything is sent: does this text fit? I assumed that was text.length <= limit. It isn't, and the two networks with the smallest limits are the two where it goes wrong. I also thought I had already solved it. Then I sat down to write this post, checked my own code against X's library to get the examples right, and it was wrong on 8 of 34 strings. So this is the corrected version, with the mistake left in. Three ways to count one string Take ok 👍🏽. That's "ok", a space, and a thumbs up with a skin tone. const text = "ok 👍🏽"; text.length; // 7 UTF-16 code units [...text].length; // 5 code points [...new Intl.Segmenter(undefined, { granularity: "grapheme" }) .segment(text)].length; // 4 what a person sees Three honest answers. Which one a network uses decides whether your post is accepted. | String | length | X counts | Bluesky counts | |---|---|---|---| | ok 👍🏽 | 7 | 5 | 4 | | 👨‍👩‍👧‍👦 (family) | 11 | 2 | 1 | | 🇪🇸 (flag) | 4 | 2 | 1 | | こんにちは | 5 | 10 | 5 | | 𝗯𝗼𝗹𝗱 (bold "font") | 8 | 8 | 4 | | read fuxux.com now | 18 | 32 | 18 | | a 140-character URL | 140 | 23 | 140 | The X column is from X's own library. The Bluesky column is the grapheme count. X: weights, not characters X publishes how it counts in an open source library, twitter-text. The limit is 280, but 280 of a weighted unit: - Latin letters, digits and common punctuation weigh 1. In code point terms: U+0000 to U+10FF, U+2000 to U+200D, U+2010 to U+201F and U+2032 to U+2037. - Everything else weighs 2. That's why five Japanese characters cost 10, and why those bold "fonts" people paste into bios cost double: they are mathematical letters far outside the Latin range. - An emoji weighs 2, as a whole. A family of four is seven code points glued with joiners and it is still 2. - Any link weighs 23, whether it is 9 characters or 140. - The text is normalised to NFC first, so e plus a combining accent counts as one é. The link rule bites in two directions. A long URL is cheaper than it looks. A short one is more expensive: fuxux.com is 9 characters and costs 23. And it catches text you didn't mean as a link. It works.So does this is 21 characters. X counts 36, because a missing space after the full stop makes works.So look like a domain (.so is a real ending). Where I got it wrong My first version walked the string one code point at a time and added 1 or 2: function weigh(text) { let total = 0; for (const char of text) total += xWeight(char.codePointAt(0)); return total; } That handles a plain 👍 (one code point, weight 2). It falls apart on anything built from several: | String | X | My first version | |---|---|---| | ok 👍🏽 | 5 | 7 | | 👨‍👩‍👧‍👦 | 2 | 11 | | 🇪🇸 | 2 | 4 | | 1️⃣ | 2 | 5 | | ❤️ | 2 | 4 | It always over-counted, so nobody could post something too long because of it. It just told people their post didn't fit when it did. On a 280 limit, a few emoji with skin tones is enough to matter. The fix is to split into graphemes first, and count a grapheme that is an emoji as 2 no matter what it is made of: const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); // Drawn as an emoji by default, or a symbol followed by the emoji // selector, or a flag letter, or a keycap like 1️⃣. const EMOJI = /\p{Emoji_Presentation}|\p{Extended_Pictographic}️|\p{Regional_Indicator}|[0-9#*]️?⃣/u; function xWeight(cp) { return (cp <= 0x10ff) || (cp >= 0x2000 && cp <= 0x200d) || (cp >= 0x2010 && cp <= 0x201f) || (cp >= 0x2032 && cp <= 0x2037) ? 1 : 2; } function weigh(text) { let total = 0; for (const { segment } of segmenter.segment(text)) { if (EMOJI.test(segment)) { total += 2; continue; } for (const char of segment) total += xWeight(char.codePointAt(0)); } return total; } function countForX(text, urlPattern) { const s = text.normalize("NFC"); let total = 0, last = 0; for (const m of s.matchAll(urlPattern)) { total += weigh(s.slice(last, m.index)) + 23; last = m.index + m[0].length; } return total + weigh(s.slice(last)); } My second attempt was also wrong, in a smaller way. I used \p{Extended_Pictographic} on its own, and © 2026 came out as 7 instead of 6. A bare © is in that Unicode class, but X only treats it as an emoji when the emoji selector (U+FE0F) follows it. Same for a text heart ❤ versus ❤️. Hence the longer pattern above. It now matches twitter-text on 43 of my 44 test strings. The one it misses is a link ending I don't carry: X knows every domain ending there is, and I keep a short list of the common ones, so a.community is a link to X and plain text to me. If you need that last case, use their list. Which is the real advice here: if you can ship the dependency, call twitter.parseTweet(text).weightedLength and be done. I wanted something small that runs in the browser as you type, so I wrote my own and test it against theirs. Bluesky: graphemes, and a second limit behind it Bluesky's limit is 300, and it counts graphemes. The post schema says so directly: maxGraphemes: 300. So the family emoji is 1, the flag is 1, and ok 👍🏽 is 4. Intl.Segmenter gives you that number in every current browser and in Node. The same schema also has maxLength: 3000, and that one is in UTF-8 bytes. Normal text never gets near it. Three hundred family emoji would, at 25 bytes each. The rest For Instagram, LinkedIn, Threads, Facebook and the others I count text.length. I have not checked each of them emoji by emoji the way I just did for X, so I won't claim more than that. Their limits are large enough (500 on Threads, 2,200 on Instagram, 3,000 on LinkedIn) that a few units either way rarely decides anything. On 280 and 300 it does. If you only need the numbers, I keep the limits for all 12 networks in one table. Try it The counter is free and needs no account: fuxux.com. Paste a post and it shows the count for each network side by side, using the rules above. Paste ok 👍🏽 and you'll see X and Bluesky disagree. The limits in it are the same numbers Fuxux uses when it publishes. If you'd rather not think about any of this, that's what the scheduler is for, and there's a free plan (3 social accounts, 5 posts a month, no card). If you know a string where X's count surprised you, put it in the comments. I'll add it to the tests.

    2 reactions · 8 comments
  2. DEV · · @bowentse

    Character Counting Is Harder Than It Looks: A Dev's Guide --- title: "Character Counting Is Harder Than It Looks: A Dev's Guide" published: true description: "String.length lies about emoji, X weights every URL as 23 characters, and SMS counts in segments. A developer's guide to counting text correctly — with code and a free tool." tags: tutorial, webdev, javascript, productivity cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com canonical_url: toolkitloop.blogspot.com --- Every developer has written something like if (text.length > 280). And every developer with users who type emoji has watched that check break in production. Counting characters is one of those problems that looks trivial until a platform, an encoding, or a family emoji proves otherwise. Here is a field guide to the three counting regimes you will actually meet — and the code that handles each. Regime 1: graphemes — what the reader sees A string is not a sequence of characters. It is a sequence of UTF-16 code units, and one visible character can be many of them: "👨‍👩‍👧‍👦".length; // 11 — but your eyes see ONE character "é".length; // 2 if built from e + combining accent The fix is the grapheme cluster — what a reader perceives as a single character — and the browser already has an API for it: const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); const count = [...segmenter.segment("👨‍👩‍👧‍👦")].length; // 1 If you build any "max N characters" validation, count segments, not .length. Your users' emoji will thank you. (One caveat: Intl.Segmenter support is broad in modern browsers but check your target — Node 16+ and all evergreen browsers have it.) Regime 2: X's weighted counting — URLs cost 23, always A standard X post allows 280 weighted characters. The weighting rules (in the open-source twitter-text config) mean: - Every URL counts as 23 characters, no matter its real length — X wraps links in its own shortener. - Some ranges (emoji included) weigh more than 1. So this announcement — 193 characters as your eyes count them — is only about 169 weighted: the 47-character URL collapses to 23 while the rocket counts double. // sketch of the idea, not the full config function weightedLength(text) { const urlRegex = /https?:\/\/\S+/g; const withoutUrls = text.replace(urlRegex, ""); const urls = (text.match(urlRegex) || []).length; // emoji & wide ranges weigh 2 (simplified) const body = [...withoutUrls].reduce( (n, ch) => n + (/\p{Extended_Pictographic}/u.test(ch) ? 2 : 1), 0); return body + urls * 23; } Moral: never validate against X's limit with a plain character count. Use the platform's rule — or a tool that applies it for you. Regime 3: SMS segments — the encoding tax SMS is counted in segments, and the segment size depends on encoding: | Encoding | Single message | Concatenated segments | |---|---|---| | GSM-7 (plain Latin) | 160 chars | 153 chars each | | UCS-2 (emoji/CJK) | 70 chars | 67 chars each | One emoji flips the entire message to UCS-2. Even € costs two GSM-7 units. A 165-character marketing text with one emoji can silently become three billable segments instead of two. If you build anything that sends SMS, surface the segment count before the user hits send. The tool I built for all three Disclosure first: I built toolkitloop.com — one of 30 free browser tools I maintain. It counts visible graphemes (family emoji = 1), has an X mode that applies the weighted URL rule, shows with/without-spaces totals, and runs 100% client-side — no signup, nothing uploaded. Handy as a pre-publish check before the platform's own composer. Your turn What is the worst character-counting bug you have shipped or seen? The emoji-that-ate-the-limit? The SMS bill surprise? Drop it in the comments — I am collecting edge cases to make the tool smarter.

    1 reaction

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

1
PackageVersionDownloads · 30dTrend · 30d
twitter-textv3.1.0-rc3
v3.1.0-rc3
≥759.4K

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
twitter/twitter-text mentions in the last 30 days