The Smallest Possible i18n: One JavaScript File, No Framework, No Build Step
I have a static HTML site with no React, no Vue, no bundler, no build step. cp is my deploy tool. I wanted to translate the whole thing into Japanese, Chinese, and French. The finished site lives at saturacorp.com, and this post walks through the i18n approach I used to build it. The translation file and runtime are visible via view-source on any page, so you can follow along with real examples as you read.
Every i18n tutorial I could find assumed a framework. react-intl, vue-i18n, Next.js's next-i18next, formatjs, they all want either a component model or a build step, usually both. The pattern is always the same: annotate your markup with abstract keys, store translations in JSON files keyed by those names, and let the build or runtime swap the strings.
That didn't fit saturacorp.com. The whole appeal of the site was that the HTML stayed human-readable. Annotating every heading and paragraph with data-i18n="home.businesses.heading" would have polluted the markup with framework apparatus before any visitor saw the page.
So I took a different path. I used CSS selectors as translation keys. This post is about what that looks like, why it worked for the site, and the specific kinds of bugs it produces that a key-based system would never have. The point isn't "here's a new way to do i18n," it's "here's an approach with a very specific shape of constraint where it fits, and a very specific failure mode that should keep you from using it almost anywhere else."
What the standard approach looks like
In a key-based i18n system, the homepage's "Group Companies" heading on saturacorp.com would look like this in the markup:
<h2 data-i18n="home.groupCompanies.heading">Group Companies & Affiliates</h2>
And the translation file:
{
"en": { "home.groupCompanies.heading": "Group Companies & Affiliates" },
"ja": { "home.groupCompanies.heading": "グループ会社・関連法人" }
}
A runtime, or a build step, finds all elements with data-i18n attributes, reads the key, looks it up in the active language's dictionary, and replaces the content. This is what every mainstream i18n library does, with framework-specific variations on the markup syntax.
What I did instead
The dictionary on saturacorp.com is keyed by CSS selectors that target the rendered DOM, not by abstract names that the HTML knows about. The entries look like this, pulled directly from the live site's saturacorp-language.js:
{
"ja": {
"#group-companies h2": "グループ会社・関連法人",
".ref-aff-chaos": "人間・AI協調システムおよび製品開発に向けたベンチャー・テクノロジー・スタジオ。",
"#privacy > p:nth-of-type(2)": "技術的開示事項として一点..."
}
}
A roughly 30-line runtime walks each entry, runs document.querySelectorAll(selector), and rewrites the matched elements' content. The HTML itself is completely untouched. No data-i18n attributes anywhere, no template syntax, no key references. View-source on saturacorp.com from any page and you'll see ordinary semantic markup with nothing translation-aware in it. The HTML doesn't know it's being translated at all.
That's the unusual part. I haven't seen a major published i18n library that uses this approach as its primary mechanism. The closest cousins are not mainstream i18n libraries but userscript translation tools, the Greasemonkey and Tampermonkey scripts that translate sites the user doesn't control. When you can't modify the HTML, you have to target what's already there with selectors. A few small experimental libraries have explored CSS-selector i18n, but none have become standard. The pattern is "known but not popular."
Why it works for this site
The constraints of saturacorp.com happened to align with the approach's strengths:
- Zero markup pollution. The HTML stays human-readable and looks like ordinary semantic markup. Six months from now, anyone reading
index.htmlcan understand it without learning a templating language. Verify this for yourself by opening view-source on saturacorp.com. - No build step. The site is "static HTML/CSS/JS deployed via
cp." That constraint was an explicit design goal. No bundler, no transpiler, no toolchain. The selector approach respects that completely. The runtime is one vanilla-JS file the browser parses directly. - Translations can be added retroactively to any existing markup. I added translations to content that had been live on saturacorp.com for weeks without modifying a single tag. With a key-based system, I'd have had to go back and annotate every element first, then keep the keys and translations in sync.
- Debuggable in DevTools. Copy a selector from the dictionary on saturacorp.com, paste it into the DevTools console as
document.querySelector(...), and immediately see what it matches. There's no abstraction layer between the dictionary and the page. - The performance is fine at this scale. 250
querySelectorAllcalls on page load runs in roughly 5 to 15ms on a modern browser. On saturacorp.com, the swap is invisible to the user.
The bug that taught me the real downside
About 200 entries in, I added a translation for the about-page's "Group Companies" section heading on saturacorp.com. The about page at saturacorp.com/about.html has this structure:
<article id="group-companies" class="legal-section">
<h3>4. Group Companies</h3>
<p>A curated selection of Group entities is presented in...</p>
</article>
So I added this to the Japanese dictionary:
"#group-companies h3": "4. グループ会社 <em>Group Companies</em>"
The translation worked correctly on the about page. I checked. Then I switched to Japanese on the homepage and noticed something odd.
The homepage also has an element with id="group-companies". It's the "Group Companies & Affiliates" grid at the bottom of saturacorp.com. Its structure looks completely different from the about page:
<section id="group-companies">
<header class="section-head">
<h2>Group Companies & Affiliates</h2>
</header>
<ul class="affiliates-grid">
<li>
<h3>Harnessing Chaos LLC</h3>
<p>Venture technology studio for human-AI orchestrated systems...</p>
</li>
<li><h3>Johnson Farms US</h3>...</li>
</ul>
</section>
The first <h3> inside #group-companies on the homepage is the first affiliate card's title, "Harnessing Chaos LLC." The selector #group-companies h3 silently matched it. In Japanese mode, the first card's title now read "4. グループ会社 Group Companies," a numbered section heading sitting inside an unrelated card grid. The card's description text below it stayed correct, which made the bug almost worse. Only the title was broken, so it read as a mysterious editorial error rather than a translation glitch.
This is the canonical CSS-selector-i18n failure mode. The selector worked fine in the context I tested it in. It silently misfired in a different context where the same selector ancestry happened to exist. A key-based i18n system would never have that collision. Abstract keys are inherently uncoupled from DOM structure. CSS selectors are inherently coupled to it.
The fix was easy. Tighten the selector to article#group-companies > h3 so it only matched the about-page's <article> element and not the homepage's <section>. But the existence of the bug taught me to audit for these collisions every time I added a translation targeting a generic ancestor-and-tag pair. Selectors like #some-id h2 or .some-class > p, any selector that doesn't have a deeply specific ancestry chain, become a candidate for silent collision.
The other trade-offs you need to be honest about
Beyond the collision issue, the CSS-selector approach has costs that key-based systems don't:
- HTML refactors silently break translations. Wrap a
<p>in a new<div>and any selector like#section > p:nth-of-type(2)stops matching. The translation just silently doesn't apply. There's no warning. The page renders in the source language for that line. - No build-time coverage validation. A real i18n toolchain can tell you "this key is referenced in markup but missing in the Japanese dictionary" at build time. The selector approach can't. It just no-ops on a missed match.
- Selector collisions get worse with scale. At 250 entries it's manageable with careful authorship. At 2,500 entries on a 50-page site, the specificity wars become a maintenance nightmare.
- No locale-aware formatting. Standard i18n libraries handle pluralization (
1 itemvsn items), dates (October 15vs15 octobrevs2026年10月15日), currencies, and so on. The selector approach offers none of that. Every translation is a static string. - Doesn't help SEO. Because the translation happens at JavaScript runtime, the static HTML that search engines see is always the source language.
hreflangtags and per-locale URL prefixes help somewhat, but the actual translated content is invisible to crawlers. On saturacorp.com, Google still indexes only the English HTML, even though/jp,/cn, and/frURLs render in those languages at runtime. - Hard to crowdsource. If you want community translators to contribute, they need to understand CSS selectors well enough to know what their string is targeting. With a key-based system, they just translate strings.
When the trade-offs make sense
Looking at that list, the approach fits a specific shape of project surprisingly well, which is the shape saturacorp.com has:
- Static, hand-curated sites where the HTML structure is stable
- Personal portfolios, parody and satire sites, landing pages, documentation
- Projects you control end-to-end (markup, translations, deployment)
- Cases where you specifically want to avoid build tools and dependencies
- Sites with translations counted in the hundreds, not thousands
- Content that is static text, not dynamic data
It does not fit:
- Anything with dynamic content (user-generated, database-driven, etc.)
- Component-based frameworks where you already have a build step
- Apps with team translators or community translation
- Anything that needs locale-aware date, number, or currency formatting
- Sites with frequently-refactored HTML
- Anything where SEO across multiple languages is mission-critical
The full runtime, conceptually
The entire i18n machinery for saturacorp.com fits in roughly 70 lines of vanilla JavaScript. The full source is at saturacorp.com/saturacorp-language.js if you want to read it directly. The shape of it:
- A flat
dictionaryobject with one block per language. Each block has atitlestring and atextobject mapping CSS selectors to translated content. - An
applyLanguage(lang)function that setsdocument.documentElement.lang, updatesdocument.title, then iterates thetextobject. For each selector key, it runsquerySelectorAllon the page, replaces the matched elements' content with the translation, and persists the choice tosessionStorage. - A
getInitialLang()function with a precedence chain: URL path prefix (for/jp,/cn,/frshareable links), URL?lang=query parameter (for sub-pages and explicit overrides), thensessionStorage(for in-session continuity across page navigation), then English as the default. - A
wireButtons()function that attaches click handlers to any[data-lang]element in the masthead language switcher. - A bootstrap block that runs both functions on
DOMContentLoaded, or immediately if the document is already past parsing.
That's the whole machinery. Markup stays clean, deploy stays as cp, the dictionary stays human-curated. The trade-off I made: selector brittleness in exchange for a simplicity ceiling that no build-tool-based system can match.
Closing
For most real products, use i18next or whatever your framework's i18n library is. The patterns are mature, the tooling is real, and the trade-offs are well understood.
For a small, hand-curated, static-HTML site where the constraint of "no build step" is itself a feature, the CSS-selector approach is genuinely better. Clean markup, zero dependencies, debuggable in DevTools, retroactively applicable. It will not scale, and you should not let it scale. Within those bounds, it's the smallest possible thing that works.
Live demo at saturacorp.com. Toggle EN, JP, CN, FR in the masthead. URLs like saturacorp.com/jp auto-load the corresponding language for share links. View-source on any page, and the markup is the markup.
JohnsonFarms.us
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.