Skip to content

Banger

check licence: GPL-3.0-or-later manifest v3 dependencies: none

Banger is a Chrome extension that shows the engagement rate of every post in your X (Twitter) timeline — replies, reposts, likes and bookmarks over views, worked out on the post in front of you. It is free, open source, and has no network access at all: it makes no requests, keeps no history, and sends nothing anywhere.

A post on X with Banger running: an arrow points at a 5.1% chip in the post header

The action bar reads 113 replies, 57 reposts, 1.1K likes and 1.6K bookmarks against 58.7K views. The chip, arrowed, reads 5.1 % — the Cooking step, which is why it carries a sparkle and an amber tint. That number won't quite match the arithmetic you'd do from the figures on screen, because it's computed from the exact integers X exposes to screen readers rather than the rounded ones it prints: 1.1K is anywhere from 1,050 to 1,149. The chip sits in the post header, left of the Grok icon, where the default placement puts it. Nothing else on the post is touched. (Post by @jaimintf.)

X shows you raw counts but never the ratio between them. 128 likes on 24K views is a very different post from 128 likes on 2K views, and there's no way to see that while scrolling. This puts the number on the post.

It computes everything locally and has no network access whatsoever — see COMPLIANCE.md for why that's the central design constraint and how it's enforced.

Install

git clone https://github.com/nikhilakki/banger.git && cd banger
  1. Open chrome://extensions
  2. Enable Developer mode (top right)
  3. Load unpacked → select this directory
  4. Open x.com

No build step. Edit a file, hit reload on the extension card, refresh the tab.

How the rate is computed

ER = (replies + reposts + likes + bookmarks) / views × 100

Views as the denominator, which is what X's own analytics reports. It's also the only denominator obtainable without network requests — a follower-based rate would mean fetching every author's profile, which is exactly what trips rate limits and bot checks.

Precision scales to magnitude, since most posts sit well under 1% where a single decimal tells you nothing:

Rate Shown
≥ 10 12 %
≥ 1 1.4 %
< 1 0.66 %

Posts below the minimum view threshold (default 100) get no badge — a rate over 12 views is noise, not signal. Hover any badge for the full breakdown.

The ladder

The chip changes with the rate, so a good post is visible from the corner of your eye before you've read the number.

Rate Tier Accent What it does
< 0.5 % Cooked Nothing. Identical to an untiered chip.
0.5 – 2 % Mid blue #1D9BF0 Tints the glass and the number.
2 – 5 % Solid green #00BA7C Same, warmer.
5 – 9 % Cooking amber #F5A524 Adds a sparkle sigil and a slow specular sweep (7.5 s).
9 – 15 % Banger ember #FF6A2B Flame sigil, breathing halo, faster sweep (5 s).
≥ 15 % Printing magenta → violet Bolt sigil, travelling gradient rim (3.4 s).

The names are the timeline's own vocabulary, not a weather report — the verdict is the part anyone actually repeats. Cooked at the bottom and Cooking at the top is the joke, and it lands on the colour ramp by accident: the ladder cools off in both directions from a post that's going well. Every tier's name shows in the hover tooltip, including the three that don't light the chip up — below Cooking the tooltip is the only place the bottom of the ladder exists.

Tier 0 is deliberately indistinguishable from a plain chip. Most posts are tier 0, and a timeline where every badge shouts is one where none of them say anything — the ladder only spends attention on the top half.

The accents are X's own palette, and nothing in the tier CSS hardcodes a fill or a text colour: every value is color-mixed against the active theme's tokens, so all five appearances (Light / Dim / Lights out / forced light / forced dark) work without a second table. Light themes pull the accent further towards ink (--bn-ink-bias) because mixing towards black is the only thing keeping amber and magenta legible on a white timeline.

Thresholds are calibrated for the default all formula. amplify runs roughly a third lower and so sits a tier cooler, which is the honest reading of a smaller numerator. All six live in one table — TIERS in src/extract.js.

Motion budget. The sweep is a compositor-only transform on a clipped child and the halo animates opacity, so neither repaints the chip. The one paint-bound animation is Printing's travelling rim, which is affordable precisely because the tier is rare. The sweep also finishes its travel at 34% of the keyframe and then holds — a continuously shimmering chip reads as a loading skeleton, not as a rank. prefers-reduced-motion, forced-colors, prefers-contrast, prefers-reduced-transparency and the flat material each strip the parts they should and keep the colour, which carries the ladder on its own.

Sigils

From Cooking up, the chip carries a mark: ✦ sparkle, flame, bolt. They're drawn as inline SVG (built with createElementNS, never innerHTML) and inherit currentColor, so they arrive already wearing the tier's accent. Emoji are offered as an alternative — 🔥 is louder and instantly legible, but it's a different shape, weight and colour on every platform, and at 9px next to an 11px number a system flame is a blob. Set Sigil to Emoji, Drawn or None.

Seeing it without loading anything

Open tools/preview.html directly in a browser. It loads the three shipped modules with no chrome.* dependency and drives the real createBadge/renderBadge, so it renders every tier across every theme and both materials through the same code path the timeline uses. The controls are deep-linkable: preview.html#tiers=color&glyph=emoji&material=flat.

Brand assets

Both are generated, not drawn once and forgotten — retune the ladder and one command brings the artwork back in sync.

Icons (npm run icons) — a flame, matching the sigil the extension puts on a Banger post, so the toolbar button and the tier-4 chip are obviously the same product. scripts/make-icons.py is stdlib-only: no Pillow, no binary source asset. The mark is a closed Bézier outline flattened to a polygon and rasterised through an exact polygon SDF, which gives analytic antialiasing at every size. That matters at 16px, where a mark either survives or turns to porridge — and it rules out the obvious shortcut of a union of disks along a spine, which is convex wherever the spine is straight and can therefore only ever produce a raindrop. The concave lick on the upper left is the whole difference between a flame and a drop.

Launch graphic (npm run promopress/banner-1600x900.png) — 16:9, sized for an X post. tools/promo.html loads the three shipped modules and calls the real createBadge/renderBadge, so the chip in the image is the chip in the product: same glass, same accent maths, same sigil. The composition is fixed at 1600px wide and centres itself vertically, so any height works — which is how the same file also produces press/social-preview-1600x800.png, the 2:1 GitHub link card. Pass a size for other formats: ./scripts/make-promo.sh 1200 1200 press/square.png.

The bookmark caveat

X renders the bookmark icon with no count. Bookmarks routinely exceed likes, so with the default all formula roughly half of every rate comes from a number that appears nowhere on the post. A real example:

aria-label: "2 replies, 3 reposts, 69 likes, 118 bookmarks, 3878 views"

all      192/3878 = 5.0 %   ← correct, but unverifiable from the post
visible   74/3878 = 1.9 %   ← every term is on screen
amplify   72/3878 = 1.9 %

Both are right; they answer different questions. all matches X's own analytics. visible is the one you can check by looking. Switch in the popup — the tooltip always spells out the actual sum either way.

Where the numbers come from

X exposes exact counts to assistive technology, even though the visible text is abbreviated to 24.4K:

<div role="group"
     aria-label="14 replies, 7 reposts, 128 likes, 12 bookmarks, 24463 views">

The parser reads that, and degrades through two more tiers if it can't:

Tier Source Precision
1 the action bar's aria-label exact
2 each button's own aria-label, keyed by data-testid exact, locale-independent
3 the abbreviated on-screen text approximate, shown with a ~

Settings

Click the toolbar icon. Chrome sizes a popup to its content and stops at 800px, and this one always asks for the full width, so it opens as two columns: Badges and Look on the left, The ladder and On the post on the right. The ladder is a live legend carrying each tier's real threshold, and it sits directly above Reaction and Sigil — the two settings that restyle it — so the choice is made by looking rather than by reading. Everything fits without scrolling.

Setting Options Notes
Show badges on / off
Minimum views number Below this, no badge. Default 100.
Formula all / visible / amplify See the bookmark caveat below.
Placement post header / action bar / both Post header (default) sits left of the Grok icon, top-right — beside the timestamp and clear of X's own counts. Action bar sits after the share icon.
Reaction full / colour only / off How loudly a chip responds to a high rate. colour keeps the palette and drops everything that moves. off restores the plain chip.
Sigil drawn / emoji / none The mark on posts from 5 % up.
Appearance auto / light / dark Auto follows X's theme. Light is a bright frosted chip with dark text that pops on a dark timeline.
Material auto / liquid glass / flat Auto drops the blur if it detects sustained long frames.
Decimals auto / 0 / 1 / 2

On auto, the chip is tuned per X theme (Light / Dim / Lights out) and stays deliberately quiet. The forced light and dark sets can't assume anything about what's behind them, so they use a much more opaque fill — a translucent chip that looks right over #000 turns to mud over a photo.

Header placement note. X's Grok button carries no data-testid and its aria-label ("Grok actions") is localized, so it isn't a safe anchor. The ... menu beside it is stable ([data-testid="caret"]), so anchorFor() climbs from the caret through X's single-child wrapper divs to the row holding both, then inserts ahead of the Grok cell — falling back to sitting just left of the menu when Grok is absent or labelled in another language.

Verifying it against a live timeline

The whole build rests on one assumption — that the aria-label carries exact integers rather than abbreviated ones. Confirm it on a real timeline by pasting tools/diagnose-snippet.js into the DevTools console. It needs no extension and works in any console context:

Verdict: 8/8 posts expose a group aria-label. All labels use EXACT integers.

With the extension loaded you can also switch the console context dropdown from top to Banger and run __banger.diagnose() for per-tier results.

Development

npm test      # parser tests — node:test, no dependencies
npm run verify   # compliance gate
npm run check    # both
npm run icons    # regenerate icons/ (stdlib python, no deps)
npm run promo    # regenerate press/ (headless Chrome screenshot of tools/promo.html)

The test suite covers the number parsing, which is the riskiest code here: the separator in 24.463 (German grouping — 24463) means something entirely different from the one in 24.4K (decimal — 24400), and getting it wrong throws the denominator off by orders of magnitude behind a confident-looking percentage.

verify-compliance.sh is a gate, not a formality — it greps shipped code for network APIs, MAIN-world injection, synthetic events, and credential access, and asserts the manifest's permission set is exactly ["storage"].

Status

Parser logic is tested against the real label format (confirmed on a live timeline: "63 replies, 348 reposts, 2427 likes, 7817 bookmarks, 908165 views" — exact integers, tier 1 works), and the compliance gate passes.

Badge rendering is confirmed in isolation — tools/preview.html renders all six tiers across all five appearances and both materials through the real createBadge/renderBadge. Placement on a live timeline is still unconfirmed: the action-bar and header anchors, and scroll performance with badges attached, need a real session.

Layout

Every src/*.js is a classic script, not an ES module. They are listed in manifest.json under content_scripts.js, injected in order into one shared isolated world, and each attaches itself to a globalThis.BN namespace.

This is deliberate. The earlier design imported them as ES modules from web_accessible_resources; that resolves fine on x.com, but a syntax error in any one module rejected the whole Promise.all and killed the extension as a single opaque unhandled rejection naming no file — which is exactly how the first build failed. Manifest injection reports a parse error per file, at the file, and needs no web-accessible resources. Order mattersglass.js and extract.js must precede badge.js.

manifest.json              MV3, permissions: ["storage"], no web_accessible_resources
src/
  glass.js                 the Liquid Glass material (CSS as a string — see COMPLIANCE.md)
  extract.js               three-tier parser
  theme.js                 Light / Dim / Lights-out detection
  settings.js              chrome.storage.local
  badge.js                 closed shadow DOM host
  diagnose.js              in-extension diagnostics
  content.js               observers, scheduling, DOM-recycling correctness
  popup/                   settings UI
tools/diagnose-snippet.js  standalone console probe
tools/preview.html         every tier × theme × material, no extension reload
tools/promo.html           launch graphic, rendered from the real badge code
press/                     launch graphic, social card (npm run promo), screenshots
llms.txt                   a summary for answer engines, per llmstxt.org
scripts/                   compliance gate, icon generator
tests/                     parser tests (load the classic script via node:vm)
.github/                   CI (npm run check), issue and PR templates

Every source file carries SPDX-License-Identifier: GPL-3.0-or-later on its first line, and the compliance gate fails if a new one doesn't.

FAQ

How is engagement rate calculated on X?

(replies + reposts + likes + bookmarks) ÷ views × 100. Views as the denominator is what X's own analytics reports, and it's the only denominator obtainable without network requests — a follower-based rate would mean fetching every author's profile. Two other formulas are available in the popup: visible drops bookmarks, so every term is one you can check by looking, and amplify counts reposts and likes only.

What is a good engagement rate on X?

There is no official benchmark, and anyone quoting one precisely is guessing. This extension treats anything under 0.5 % as flat, 2–5 % as solid, and 5 % and up as genuinely good — six steps, calibrated on the default all ÷ views formula. The thresholds are one table, TIERS in src/extract.js, and the popup shows every one of them.

Does Banger send my data anywhere?

No. The codebase contains no fetch, XMLHttpRequest, sendBeacon, WebSocket or EventSource, and the manifest declares no host_permissions, so it is structurally incapable of making a request. Its one permission is storage, holding your nine settings and nothing else. Post metrics live in an in-memory cache that is cleared on navigation and dies with the tab. scripts/verify-compliance.sh checks all of that mechanically on every run.

Can a Chrome extension like this get my X account suspended?

There is no documented case of DOM or UI modification alone causing a suspension. The suspension waves that did happen trace to extensions that originated API traffic — OldTweetDeck proxied legacy API calls through an XMLHttpRequest patch — while display-only extensions have run for years across hundreds of thousands of users without incident. Banger cannot make a request at all, so it cannot produce that signal. The longer answer, including what X's bot-detection actually looks for and where this extension is detectable, is in COMPLIANCE.md.

Which browsers does it work in?

Chromium browsers: Chrome, Edge, Brave, Arc, Opera, Vivaldi. It is a plain Manifest V3 extension with one permission and no build step. Firefox and Safari are untested and would need manifest changes.

Does it work on twitter.com as well as x.com?

Yes. The content script matches both https://x.com/* and https://twitter.com/*.

Is it on the Chrome Web Store?

No. Install it unpacked — clone the repo, then Load unpacked at chrome://extensions, which takes about a minute and needs no build. Whether this belongs in a store at all is a genuine question rather than a to-do; see DISCLAIMER.md.

Why don't all posts get a badge?

Three reasons, all deliberate. Posts under the minimum view count get none (default 100 — a rate over a dozen views is noise, and you can set the floor to 0). Promoted posts are skipped. And posts X renders without a view count, such as some older replies, have no denominator to divide by.

Why doesn't the percentage match the numbers I can see?

Because it is computed from the exact integers X exposes to assistive technology, not the abbreviated ones it prints: 1.1K on screen is anywhere from 1,050 to 1,149. On the default formula it also counts bookmarks, which X does not display on the timeline at all and which routinely exceed likes. Switch Formula to Visible ÷ views for a rate whose every term is on screen.

Is it free?

Yes, and open source under GPL-3.0-or-later. No account, no sign-in, no telemetry, no paid tier.

Contributing

Pull requests are welcome. CONTRIBUTING.md covers setup, house style, and the one hard rule: the six guarantees in COMPLIANCE.md are not negotiable in a patch. npm run check has to pass, and CI runs the same command.

For a security problem, please report it privately — see SECURITY.md. Everyone taking part is expected to follow the Code of Conduct.

Licence

GNU General Public License v3.0 or later. Copyright © 2026 Nikhil (@nikhiltf).

You may use, study, modify and redistribute this, including a fork on an extension store — provided the source of what you ship stays available under the same licence. It comes with no warranty.

Disclaimer

Not affiliated with, endorsed by, or sponsored by X Corp. X, Twitter and Grok are trademarks of their respective owners, used here only to name the site this runs on.

The rate is a reading, not a report: with the default all formula, part of every number comes from a bookmark count X doesn't display. And X's terms on automated access make this a gray area, discussed honestly rather than hand-waved. Both, in full, in DISCLAIMER.md — worth the two minutes before you install it.

About

Chrome extension that shows the engagement rate on every post in your X (Twitter) timeline. Computed on your device — no network access, no tracking, no account. Manifest V3, zero dependencies.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages