DEV Community

Maegami
Maegami

Posted on

Adding a third language to a static HTML site: ~60 lines of vanilla JS

Yesterday a lead from Ukraine asked to see my work. My case study was in Russian. Sending a Russian-language portfolio to a Ukrainian client is a good way to lose them before hello.

The site is static HTML on Vercel. No framework, no build step, and I wanted to keep it that way. An i18n library for two pages felt like overkill, so here's what I did instead.

Russian stays in the DOM, other languages are dictionaries

The page source is the Russian version. On load I walk all text nodes once and remember their original value, whitespace and a normalized key:

function norm(s){ return s.replace(/[\s ]+/g,' ').trim(); }
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
var nodes = [];
while (walker.nextNode()) {
  var n = walker.currentNode;
  var key = norm(n.nodeValue);
  if (!key) continue;
  n.__ru = n.nodeValue; n.__key = key;
  n.__lead = n.nodeValue.match(/^[\s ]*/)[0];
  n.__trail = n.nodeValue.match(/[\s ]*$/)[0];
  nodes.push(n);
}
Enter fullscreen mode Exit fullscreen mode

EN and UK are plain objects keyed by the normalized Russian string. Switching languages is rewriting nodeValue, nothing else:

function setLang(lang, persist){
  var dict = lang === 'en' ? EN : (lang === 'uk' ? UK : null);
  nodes.forEach(function(node){
    node.nodeValue = dict ? (node.__lead + (dict[node.__key] || node.__ru) + node.__trail) : node.__ru;
  });
  document.documentElement.lang = lang;
  if (persist) localStorage.setItem('langManual', '1');
  localStorage.setItem('lang', lang);
}
Enter fullscreen mode Exit fullscreen mode

If a string is missing from the dictionary it stays Russian, so a half-translated page still works.

Detection order matters more than detection

  1. manual choice from localStorage wins forever
  2. otherwise country by IP (ipapi.co free tier, 1.5s timeout), UA gets Ukrainian
  3. if the IP call is slow or blocked, navigator.language already picked a language, so nothing blinks

The first version waited for the IP response and the page sat in Russian for a second. Felt broken. Render from browser language immediately, treat geo as an upgrade.

Limitations, honestly: this only swaps text nodes. Attributes like placeholder or aria-label need separate handling, and if one sentence is split across several tags the keys get ugly. For a portfolio it's more than enough.

Live: https://maegamidev.vercel.app (dropdown top right). The case study it was built for: https://maegamidev.vercel.app/euphoria.html

Sent the links to the lead the same evening.

Top comments (4)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Rendering from navigator.language right away and treating the IP lookup as an upgrade is the detail that makes this feel finished instead of hacky, since the version that waited on geo is exactly the second of Russian-then-flicker that reads as broken. On the attribute gap you flagged: if you ever need placeholders or aria-labels covered, you can keep the same shape by tagging those elements with a data-i18n-attr and walking them in a second tiny pass, so the dictionary stays one flat object and you don't drag in a library for it. For a two-page portfolio though, swapping text nodes is genuinely the right amount of engineering.

Collapse
 
maegamidev profile image
Maegami

the data-i18n-attr second pass is a neat idea, keeps the dictionary flat and still no deps. right now the portfolio has no forms so text nodes cover everything, but i'll steal this when inputs show up. thanks for actually reading down to the limitations.

Collapse
 
pakvothe profile image
Franco Ortiz

"An i18n library for two pages felt like overkill" is the most honest line about i18n tooling I've read in a while. The setup cost of most libs is wildly out of proportion for small sites.

For your client work it might be worth a look at i1n (i1n.ai), I built it to be the opposite of overkill: npx i1n init, JSON stays as plain JSON, one command translates to any language. For a freelancer shipping store templates, adding Ukrainian (or any language) becomes a 2-minute task instead of hand-written JS. Free tier covers small client sites.

Collapse
 
maegamidev profile image
Maegami

fair pitch. for client stores i'm usually inside a framework with proper i18n anyway, this hack was strictly for the no-build static case. bookmarked.