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.
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.
git clone https://github.com/nikhilakki/banger.git && cd banger- Open
chrome://extensions - Enable Developer mode (top right)
- Load unpacked → select this directory
- Open x.com
No build step. Edit a file, hit reload on the extension card, refresh the tab.
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 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.
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.
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.
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 promo → press/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.
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.
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 ~ |
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.
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.
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"].
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.
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 matters — glass.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.
(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.
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.
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.
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.
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.
Yes. The content script matches both https://x.com/* and
https://twitter.com/*.
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.
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.
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.
Yes, and open source under GPL-3.0-or-later. No account, no sign-in, no telemetry, no paid tier.
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.
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.
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.
