ohm
by
← home ·

Docs

Everything you need to know about ohm: what it will and won't do, the filter syntax it understands, which selectors survive a redeploy, and what to do when a rule silently does nothing.

01What ohm is (and isn't)

ohm runs your own hand-written cosmetic filters — rules like x.com##div[data-testid="metadata"] — as a Chrome extension, scoped to only the hosts those rules mention. That's the entire feature set. It exists because Chrome's Manifest V2 removal killed uBlock Origin, and with it, years of personal cosmetic-filter lists that had nowhere left to run.

A few things it deliberately doesn't do, stated plainly because the category invites scope creep:

02Two ways to install

Both paths run the exact same parsing logic and the exact same rule engine — same syntax, same durability advice, same "nothing leaves your device" guarantee. What differs is how permission and updates work.

Chrome Web StoreBuild your own
PermissionsAll sites, granted once at installOnly the hosts your rules name, nothing else
InstallOne clickUnzip, Developer mode, Load unpacked
UpdatesCan receive engine fixes automaticallyFrozen until you manually rebuild and reload
Editing rulesPaste into the settings page anytimeRe-paste into the generator, download again

Neither path blocks ads, tracks you, or has any code capable of a network request. The difference is entirely about permission scope and how much manual ceremony you're willing to trade for convenience.

Trust, but verify

The Store version asking for every site up front is a real, higher trust ask than the generator's zero-permission download — worth being direct about rather than glossing over. Here's exactly what that permission is for and how to check the claim yourself instead of taking it on faith:

03How it works

The generator parses your pasted list entirely in your browser tab — nothing you paste is ever sent anywhere. It groups the surviving rules by host, builds a Manifest V3 extension around them, and packages it as a zip:

  1. Paste. A uBlock Origin cosmetic filter list — the same .txt you were using before.
  2. Parse. Client-side JavaScript splits it into rules, drops what ohm can't run (see below), and reports what it kept and skipped.
  3. Build. A manifest.json scoped to exactly the hosts your surviving rules mention, plus a rules.js data file and a content.js that applies them.
  4. Download & load. Unzip, open chrome://extensions, enable Developer mode, click Load unpacked, pick the folder.

What that looks like in practice — one rule, applied:

Before ohm
example.com
Someone
@someone
Just shipped a new feature. Feeling pretty good about it.
142 replies · 891 reposts · 6.2K likes
After ohm
example.com
Someone
@someone
Just shipped a new feature. Feeling pretty good about it.

↑ hidden by one rule: example.com##.counts:remove()

There's no server anywhere in this pipeline. The generator is a static page; the extension it produces has no host_permissions beyond the sites you named and no code path that could reach the network even if it wanted to.

04Filter syntax reference

Each rule is one line: host##selector. The host can be a comma-separated list (x.com,pro.x.com##...); each host gets its own copy of the rule. host#?#selector (uBO's "extended" cosmetic syntax) is accepted the same way. If you have rules for both a domain and one of its subdomains — x.com and pro.x.com, say — both groups apply together on the subdomain; the parent domain's rules don't get replaced by the more specific ones, they combine.

What gets skipped, and why

Line looks likeWhy it's skipped
||doubleclick.net^$third-partyNetwork filter — ohm only does cosmetic filters, never blocks requests
x.com#@#.adException rule — nothing to except, since ohm doesn't ship default rules
x.com##+js(...)Scriptlet injection — out of scope, only plain content-script behavior is supported
x.com##^script:has-text(...)HTML filter — not implementable as a content-script style/DOM tweak
*##.ad or example.*##.adWildcard host — ohm needs a concrete host to scope the extension's permissions to
example.com,~x.example.com##.adExclusion hosts (~) aren't supported — the whole line is skipped rather than silently dropping just the exclusion
https://example.com##.adHost can't include a URL scheme — use the bare domain (example.com), not the full URL

Every skipped line is listed with its line number and reason in the README.md bundled into your download, so nothing disappears silently at import time.

Supported procedural operators

Beyond plain CSS selectors (which just get hidden with display: none !important), ohm supports one procedural operator per rule, in the form selector:op(argument):

OperatorExampleWhat it does
has-textspan:has-text(Sponsored)Hides the matched element if its text content includes the given string
upwardspan:upward(3)Walks up 3 parent elements from the match and hides that ancestor instead
upwardspan:upward(.card)Non-numeric argument: hides the nearest ancestor matching that selector, via closest()
nth-ancestorspan:nth-ancestor(2)Alias for the numeric form of upward
remove-classb:remove-class(badge-verified)Strips one or more classes (comma-separated) from the match, leaving it in place
remove-attra:remove-attr(target)Strips one or more attributes (comma-separated) from the match
remove.toast:remove()Removes the matched element from the DOM entirely
style.card:style(opacity: 0.4;)Appends raw CSS declarations to the match's inline style — include !important yourself if you need it

Only one procedural operator per rule. ohm's content script matches each rule against a single flat pattern, base-selector:op(argument), anchored to the end of the string. It cannot parse two chained or nested ops in one selector.

Won't work: div:has(> span:has-text("Suggested Videos")):upward(6) — nesting :has-text() inside :has(), then chaining :upward() after it. This parses without any error and ships in your zip, but silently matches nothing at runtime, because the resulting "base selector" ohm tries to run is left with unbalanced parentheses.

Works: span:has-text(Suggested Videos) — one flat operator, applied directly. If you need to hide a specific ancestor rather than the text node itself, use :upward() alone against the most specific selector you can find for that ancestor, rather than combining it with a text match.

05Durability: which selectors last

This is the single highest-value thing to get right in your list, because it's the difference between rules that survive a year and rules that break every time a site redeploys its frontend.

Fragile — avoid when you have a choice

Durable — prefer these

When both exist for the same element, prefer the attribute selector. If only a generated class is available, it'll probably still work today — just expect to revisit it eventually, and know that when it silently stops, the fix is almost always "find the new generated class and swap it," not "the whole approach is broken."

06Troubleshooting

ohm's own principle is to degrade loudly, not silently — but a rule that's syntactically valid and simply targets the wrong element has no way to announce that. Here's how to tell the difference between "not installed right" and "the selector stopped matching."

Confirm the extension is even running

  1. Open chrome://extensions and confirm ohm is listed and enabled, with no errors badge.
  2. On a page one of your rules targets, open DevTools → Elements, and search (Ctrl/Cmd+F in the panel) for a <style> tag containing one of your selectors. If your list had any plain CSS rules for that host, this tag should exist — if it doesn't, the content script isn't running on that page at all (wrong host scoping is the usual cause).

A CSS rule isn't hiding anything

Copy the selector out of the injected <style> tag and paste it into the DevTools console as document.querySelectorAll('your-selector'). An empty result means the selector no longer matches anything on the page — the site changed its markup. Zero results is the most common failure mode and is almost always a durability problem (see above).

A procedural rule (has-text, upward, etc.) isn't working

07FAQ

Why did uBlock Origin stop working?

Chrome removed the Manifest V2 code paths in 2025, including the enterprise policy that had been keeping MV2 extensions alive. uBlock Origin's full feature set — network-level blocking via webRequestBlocking — depends on APIs Manifest V3 doesn't have. uBlock Origin Lite is Chrome's sanctioned MV3 replacement, but it's deliberately declarative: no custom filters, no element picker, no dashboard.

Why can't ohm block ads or network requests?

By design, not by platform limitation. Cosmetic filtering — hiding elements with CSS after the page loads — never depended on the APIs MV3 removed; it's just a content script, which MV3 still supports fine. Network-level blocking is a different, larger problem this project isn't trying to solve. If you want that too, run uBlock Origin Lite alongside ohm — they don't conflict.

Is my filter list private?

Yes. The generator runs entirely in your browser tab — there's no server it talks to, and the extension it produces has zero network permissions. You can verify this yourself: view-source the generator page, or read the roughly 250-line content.js that ends up in your download.

Why does the generator still need "Load unpacked" if there's a Store version?

Because every generated build is a unique, personal file scoped to exactly your own rules — there's nothing generic there to publish to a store. The Store listing solves a different problem (zero-friction install) at the cost of a different trade-off (broad permission, one shared package). See Two ways to install above for the full comparison — if you'd rather keep permissions as small as possible and don't mind the extra clicks, the generator's download is still the more minimal-trust option.