Web Personal con i18n: Selector de Idiomas en Vanilla JS
📋 Contexto
Mi web personal (mytechzone.dev) estaba solo en español. Quería agregarle inglés para llegar a audiencia internacional, pero sin complicarme con frameworks (React, Vue) o librerías pesadas (i18next). Solución: implementar i18n con vanilla JavaScript + localStorage.
🎯 Requisitos
- ✅ Toggle EN/ES con botones
- ✅ Persistencia con localStorage
- ✅ Inglés como idioma por defecto
- ✅ Sin recargar la página
- ✅ Sin frameworks ni dependencias
- ✅ Lightweight (< 5 KB JavaScript)
🛠️ Implementación
Paso 1: HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kevin Romero | DevOps Engineer</title>
</head>
<body>
<!-- Language Toggle -->
<div class="language-toggle">
<button id="lang-en" class="active">EN</button>
<button id="lang-es">ES</button>
</div>
<!-- Content with data attributes -->
<header>
<h1>Kevin Romero</h1>
<p id="header-subtitle" data-en="DevOps Engineer | SRE | Cloud Infrastructure"
data-es="Ingeniero DevOps | SRE | Infraestructura Cloud">
DevOps Engineer | SRE | Cloud Infrastructure
</p>
</header>
<section>
<h2 id="about-title">About</h2>
<p id="about-text">...</p>
</section>
<script src="lang.js"></script>
</body>
</html>
data-* attributes cuando el contenido es corto (headers, labels). Para textos largos, usar un objeto JavaScript translations.
Paso 2: CSS (Language Toggle)
.language-toggle {
position: absolute;
top: 2rem;
right: 2rem;
display: flex;
gap: 0.5rem;
z-index: 100;
}
.language-toggle button {
background: #1a1a1a;
color: #888;
border: 1px solid #333;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 4px;
font-weight: 600;
transition: all 0.2s;
}
.language-toggle button:hover {
border-color: #60a5fa;
color: #60a5fa;
}
.language-toggle button.active {
background: #60a5fa;
color: #0a0a0a;
border-color: #60a5fa;
}
Paso 3: JavaScript (lang.js)
// translations.js
const translations = {
// IDs mapped to translations
"header-subtitle": {
"en": "DevOps Engineer | SRE | Cloud Infrastructure",
"es": "Ingeniero DevOps | SRE | Infraestructura Cloud"
},
"about-title": {
"en": "About",
"es": "Acerca de"
},
"about-text": {
"en": "I'm a DevOps Engineer passionate about automation, cloud infrastructure, and continuous improvement. I build reliable systems that scale.",
"es": "Soy Ingeniero DevOps apasionado por la automatización, infraestructura cloud y mejora continua. Construyo sistemas confiables que escalan."
},
"projects-title": {
"en": "Projects",
"es": "Proyectos"
},
"project-homelab": {
"en": "Home Lab Server built from old notebook running Debian, Docker, n8n, and monitoring system",
"es": "Servidor Home Lab construido con notebook antigua ejecutando Debian, Docker, n8n y sistema de monitoreo"
},
"contact-title": {
"en": "Get in Touch",
"es": "Contacto"
},
"footer-text": {
"en": "Built with passion. Hosted on my home lab.",
"es": "Construido con pasión. Alojado en mi laboratorio casero."
}
};
// Set language function
function setLanguage(lang) {
// Update html lang attribute
document.documentElement.lang = lang;
// Save to localStorage
localStorage.setItem('lang', lang);
// Update all elements with translations
Object.keys(translations).forEach(id => {
const element = document.getElementById(id);
if (element && translations[id][lang]) {
element.textContent = translations[id][lang];
}
});
// Update button states
document.querySelectorAll('.language-toggle button').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(`lang-${lang}`).classList.add('active');
}
// Event listeners
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
document.getElementById('lang-es').addEventListener('click', () => setLanguage('es'));
// Initialize: Load saved language or default to English
const savedLang = localStorage.getItem('lang') || 'en';
setLanguage(savedLang);
Versión Optimizada (Con data-* attributes)
// lang-optimized.js
const translations = {
"about-text": {
"en": "I'm a DevOps Engineer...",
"es": "Soy Ingeniero DevOps..."
},
// Only for long texts
};
function setLanguage(lang) {
document.documentElement.lang = lang;
localStorage.setItem('lang', lang);
// Method 1: Update from translations object
Object.keys(translations).forEach(id => {
const element = document.getElementById(id);
if (element && translations[id][lang]) {
element.textContent = translations[id][lang];
}
});
// Method 2: Update from data-* attributes
document.querySelectorAll('[data-en]').forEach(element => {
const key = element.id;
if (element.dataset[lang]) {
element.textContent = element.dataset[lang];
}
});
// Update active button
document.querySelectorAll('.language-toggle button').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(`lang-${lang}`).classList.add('active');
}
// Event listeners
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
document.getElementById('lang-es').addEventListener('click', () => setLanguage('es'));
// Initialize
const savedLang = localStorage.getItem('lang') || 'en';
setLanguage(savedLang);
💡 Mejoras Adicionales
1. Detectar Idioma del Navegador
// Detect browser language
function getBrowserLanguage() {
const browserLang = navigator.language || navigator.userLanguage;
// Extract language code (e.g., "en-US" → "en")
const langCode = browserLang.split('-')[0];
// Check if we support this language
return ['en', 'es'].includes(langCode) ? langCode : 'en';
}
// Initialize with browser language if no saved preference
const savedLang = localStorage.getItem('lang') || getBrowserLanguage();
setLanguage(savedLang);
2. Smooth Transitions
// Add fade effect when changing language
function setLanguage(lang) {
// ... (previous code)
// Add fade animation
document.querySelectorAll('[data-en]').forEach(element => {
element.style.opacity = '0';
element.style.transition = 'opacity 0.2s';
setTimeout(() => {
element.textContent = element.dataset[lang];
element.style.opacity = '1';
}, 200);
});
}
// Or use CSS
.translatable {
transition: opacity 0.2s ease-in-out;
}
.translatable.changing {
opacity: 0;
}
3. Flag Icons (Opcional)
<div class="language-toggle">
<button id="lang-en">
🇬🇧 EN
</button>
<button id="lang-es">
🇪🇸 ES
</button>
</div>
<!-- Or use SVG flags -->
<button id="lang-en">
<img src="/flags/en.svg" alt="English"> EN
</button>
📊 Resultado Final
• JavaScript: 2.8 KB (minified)
• Sin dependencias externas
• Cambio de idioma instantáneo
• Preferencia persistente (localStorage)
• Funciona offline
• Compatible con todos los navegadores
• SEO-friendly (html[lang] attribute)
🐛 Problemas Comunes y Soluciones
Problema: Contenido "flashea" en idioma incorrecto
Causa: HTML carga con texto default antes de que JavaScript cargue.
Solución: Inline el script de idioma en el <head>
<head>
<script>
// Inline critical JS
(function() {
const lang = localStorage.getItem('lang') || 'en';
document.documentElement.lang = lang;
document.documentElement.classList.add('lang-' + lang);
})();
</script>
</head>
Problema: Nuevos elementos dinámicos no se traducen
Solución: Crear función para traducir elementos individuales
function translateElement(element, key) {
const currentLang = localStorage.getItem('lang') || 'en';
if (translations[key] && translations[key][currentLang]) {
element.textContent = translations[key][currentLang];
}
}
// Usage
const newElement = document.createElement('p');
newElement.id = 'dynamic-text';
translateElement(newElement, 'dynamic-text');
Problema: SEO (Google ve solo un idioma)
Solución: Agregar <link rel="alternate">
<head>
<link rel="alternate" hreflang="en" href="https://mytechzone.dev?lang=en">
<link rel="alternate" hreflang="es" href="https://mytechzone.dev?lang=es">
<link rel="alternate" hreflang="x-default" href="https://mytechzone.dev">
</head>
💡 Alternativas Consideradas
- i18next: 🔴 50 KB, overkill para sitio simple
- React + react-i18next: 🔴 Framework completo, demasiado pesado
- Páginas separadas (index-en.html, index-es.html): 🔴 Duplicación, difícil mantenimiento
- Server-side i18n: 🟡 Mejor para SEO, pero requiere backend
- Vanilla JS: ✅ Lightweight, suficiente para mi caso
📈 Métricas
Before (Spanish only):
- Visitors: ~60% LatAm, 40% lost (non-Spanish speakers)
- Bounce rate: 45%
After (EN/ES):
- Visitors: +30% (international audience)
- Bounce rate: 32%
- 65% use English, 35% Spanish
- localStorage adoption: 89% (users come back with saved preference)
🚀 Extensiones Futuras
- 🌐 Agregar más idiomas (pt, fr)
- 📝 Traducir blog posts (diferentes archivos HTML por idioma)
- 🔊 Pronunciación de nombre con audio
- 🤖 Auto-traducción con API (Google Translate API)
📚 Código Completo (GitHub)
Puedes ver la implementación completa en mi GitHub:
- Personal Website Repository
- Archivo:
website/index.html
💭 Conclusión
No siempre necesitas frameworks complejos. Para un sitio personal estático con i18n, vanilla JavaScript es perfecto: rápido, simple, sin dependencias. La implementación completa me tomó 2 horas y el resultado es un sitio bilingüe profesional.
Si tu caso de uso es más complejo (cientos de strings, múltiples idiomas, CMS), entonces sí considera i18next o frameworks. Pero para proyectos pequeños, KISS (Keep It Simple, Stupid) funciona mejor.