The Smallest Possible i18n: One JavaScript File, No Framework, No Build Step


I have a static HTML site — 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. 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. The whole appeal of the site I was building 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 this 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.”

Live demo: 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; the markup is the markup.

What the standard approach looks like

In a key-based i18n system, the homepage’s “Group Companies” heading 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 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

My dictionary is keyed by CSS selectors that target the rendered DOM, not by abstract names that the HTML knows about. The dictionary entries look like this:

{
  "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. 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, Greasemonkey / 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 the project 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.html can understand it without learning a templating language.
  • 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 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, 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 querySelectorAll calls on page load runs in roughly 5–15ms on a modern browser. For a small site, invisible.

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. The about page has the 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 the page. 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. #some-id h2, .some-class > p — any selector that doesn’t have a deeply specific ancestry chain is 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 item / n items), dates (October 15 vs 15 octobre vs 2026年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. hreflang tags and per-locale URL prefixes help somewhat, but the actual translated content is invisible to crawlers.
  • 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:

  • Static, hand-curated sites where the HTML structure is stable
  • Personal portfolios, parody / 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 the site fits in roughly 70 lines of vanilla JavaScript. The shape of it:

  • A flat dictionary object with one block per language. Each block has a title string and a text object mapping CSS selectors to translated content.
  • An applyLanguage(lang) function that sets document.documentElement.lang, updates document.title, then iterates the text object, for each selector key, querySelectorAll the page, replace the matched elements’ content with the translation, persist the choice to sessionStorage.
  • A getInitialLang() function with a precedence chain: URL ?lang= query parameter (for shareable links), then sessionStorage (for in-session continuity across pages), 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: 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; the markup is the markup.