Ver contenido
Guía de Implementación de Atributos de Idioma: Patrones Completos para Accesibilidad Web Multilingüe
Introducción
La identificación correcta del idioma es fundamental para la accesibilidad web. Esta guía cubre patrones completos de implementación para el atributo lang, etiquetas de idioma BCP 47, manejo de contenido multilingüe y enfoques específicos para frameworks que cumplen con los requisitos de WCAG 3.1.1 y 3.1.2.
Más allá de la accesibilidad, los atributos de idioma correctos habilitan la corrección ortográfica, separación silábica, selección de fuentes y optimización de motores de búsqueda para contenido multilingüe.
Implementación HTML Completa
Plantilla de Idioma de Página
<!DOCTYPE html>
<html lang="es" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Declaración de idioma en meta (suplementario) -->
<meta http-equiv="content-language" content="es">
<!-- Versiones en idiomas alternativos (SEO y accesibilidad) -->
<link rel="alternate" hreflang="en" href="https://ejemplo.com/en/pagina">
<link rel="alternate" hreflang="es" href="https://ejemplo.com/es/pagina">
<link rel="alternate" hreflang="x-default" href="https://ejemplo.com/es/pagina">
<title>Título de Página</title>
</head>
<body>
<!-- Contenido en idioma principal (Español) -->
<!-- Sección en diferente idioma -->
<section lang="en">
<h2>Section in English</h2>
<p>This content is in English.</p>
</section>
<!-- Cambios de idioma en línea -->
<p>
El concepto de <span lang="de">Weltanschauung</span> se refiere a una
visión del mundo.
</p>
</body>
</html>
Estructura de Etiqueta de Idioma BCP 47
idioma-extlang-script-region-variante-extension-uso-privado
Ejemplos:
es → Español (general)
es-ES → Español (España)
es-MX → Español (México)
zh-Hans → Chino (script Simplificado)
zh-Hant → Chino (script Tradicional)
zh-CN → Chino (región China, implica Simplificado)
zh-TW → Chino (región Taiwán, implica Tradicional)
pt-BR → Portugués (Brasil)
sr-Latn → Serbio (script Latino)
sr-Cyrl → Serbio (script Cirílico)
Referencia Completa de Etiquetas de Idioma
const ETIQUETAS_IDIOMA = {
// Idiomas Principales
'es': 'Español',
'es-ES': 'Español (España)',
'es-MX': 'Español (México)',
'es-AR': 'Español (Argentina)',
'es-CO': 'Español (Colombia)',
'es-CL': 'Español (Chile)',
'en': 'Inglés',
'en-US': 'Inglés (Estados Unidos)',
'en-GB': 'Inglés (Reino Unido)',
'en-AU': 'Inglés (Australia)',
'en-CA': 'Inglés (Canadá)',
'fr': 'Francés',
'fr-FR': 'Francés (Francia)',
'fr-CA': 'Francés (Canadá)',
'fr-BE': 'Francés (Bélgica)',
'de': 'Alemán',
'de-DE': 'Alemán (Alemania)',
'de-AT': 'Alemán (Austria)',
'de-CH': 'Alemán (Suiza)',
'pt': 'Portugués',
'pt-BR': 'Portugués (Brasil)',
'pt-PT': 'Portugués (Portugal)',
// Idiomas Asiáticos
'zh': 'Chino',
'zh-Hans': 'Chino (Simplificado)',
'zh-Hant': 'Chino (Tradicional)',
'zh-CN': 'Chino (China)',
'zh-TW': 'Chino (Taiwán)',
'zh-HK': 'Chino (Hong Kong)',
'ja': 'Japonés',
'ja-JP': 'Japonés (Japón)',
'ko': 'Coreano',
'ko-KR': 'Coreano (Corea del Sur)',
// Idiomas RTL
'ar': 'Árabe',
'ar-SA': 'Árabe (Arabia Saudita)',
'ar-EG': 'Árabe (Egipto)',
'he': 'Hebreo',
'fa': 'Persa/Farsi',
// Otros Europeos
'it': 'Italiano',
'nl': 'Holandés',
'ru': 'Ruso',
'pl': 'Polaco',
'sv': 'Sueco',
'da': 'Danés',
'no': 'Noruego',
'fi': 'Finlandés',
'el': 'Griego',
'tr': 'Turco',
// Clásicos/Históricos
'la': 'Latín',
'grc': 'Griego Antiguo',
};
Implementaciones en Frameworks
React con i18n
import React, { createContext, useContext, useEffect } from 'react';
// Contexto de Idioma
interface LanguageContextType {
language: string;
setLanguage: (lang: string) => void;
dir: 'ltr' | 'rtl';
}
const LanguageContext = createContext<LanguageContextType | null>(null);
// Idiomas RTL
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
export function LanguageProvider({
children,
defaultLanguage = 'es'
}: {
children: React.ReactNode;
defaultLanguage?: string;
}) {
const [language, setLanguageState] = React.useState(defaultLanguage);
const dir = RTL_LANGUAGES.some(l => language.startsWith(l)) ? 'rtl' : 'ltr';
const setLanguage = (lang: string) => {
setLanguageState(lang);
// Actualizar atributos del documento
document.documentElement.lang = lang;
document.documentElement.dir = RTL_LANGUAGES.some(l => lang.startsWith(l))
? 'rtl'
: 'ltr';
};
// Establecer idioma inicial del documento
useEffect(() => {
document.documentElement.lang = language;
document.documentElement.dir = dir;
}, []);
return (
<LanguageContext.Provider value={{ language, setLanguage, dir }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage debe usarse dentro de LanguageProvider');
}
return context;
}
// Componente para cambios de idioma en línea
interface LangSpanProps {
lang: string;
children: React.ReactNode;
className?: string;
}
export function LangSpan({ lang, children, className }: LangSpanProps) {
const isRtl = RTL_LANGUAGES.some(l => lang.startsWith(l));
return (
<span
lang={lang}
dir={isRtl ? 'rtl' : undefined}
className={className}
>
{children}
</span>
);
}
// Componente para cambios de idioma a nivel de bloque
interface LangBlockProps {
lang: string;
children: React.ReactNode;
as?: 'div' | 'section' | 'article' | 'blockquote';
className?: string;
}
export function LangBlock({
lang,
children,
as: Component = 'div',
className
}: LangBlockProps) {
const isRtl = RTL_LANGUAGES.some(l => lang.startsWith(l));
return (
<Component
lang={lang}
dir={isRtl ? 'rtl' : 'ltr'}
className={className}
>
{children}
</Component>
);
}
// Ejemplo de Uso
function ContenidoMultilingue() {
return (
<div>
<p>
La palabra alemana <LangSpan lang="de">Schadenfreude</LangSpan> describe
el placer derivado de la desgracia de otros.
</p>
<LangBlock lang="en" as="blockquote">
<p>To be or not to be, that is the question.</p>
<cite>— William Shakespeare</cite>
</LangBlock>
<LangBlock lang="ar" as="section">
<h2>مقطع باللغة العربية</h2>
<p>هذا النص مكتوب باللغة العربية.</p>
</LangBlock>
</div>
);
}
Implementación en Vue 3
<script setup lang="ts">
import { ref, computed, watch, onMounted, provide } from 'vue'
// Composable de idioma
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
export function useLanguage(defaultLanguage = 'es') {
const language = ref(defaultLanguage)
const dir = computed(() =>
RTL_LANGUAGES.some(l => language.value.startsWith(l)) ? 'rtl' : 'ltr'
)
function setLanguage(lang: string) {
language.value = lang
document.documentElement.lang = lang
document.documentElement.dir = RTL_LANGUAGES.some(l => lang.startsWith(l))
? 'rtl'
: 'ltr'
}
onMounted(() => {
document.documentElement.lang = language.value
document.documentElement.dir = dir.value
})
watch(language, (newLang) => {
document.documentElement.lang = newLang
document.documentElement.dir = RTL_LANGUAGES.some(l => newLang.startsWith(l))
? 'rtl'
: 'ltr'
})
return { language, dir, setLanguage }
}
// Usando en componente
const { language, dir, setLanguage } = useLanguage('es')
// Proporcionar a componentes hijos
provide('language', { language, dir, setLanguage })
</script>
<template>
<div>
<!-- Selector de idioma -->
<select @change="setLanguage(($event.target as HTMLSelectElement).value)">
<option value="es">Español</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
<!-- Cambio de idioma en línea -->
<p>
La frase en inglés <span lang="en">that's life</span> significa "así es la vida".
</p>
<!-- Cambio de idioma en bloque con soporte RTL -->
<blockquote lang="ar" dir="rtl">
<p>السلام عليكم</p>
</blockquote>
<!-- Sección en diferente idioma -->
<section lang="en">
<h2>Section in English</h2>
<p>This content is completely in English.</p>
</section>
</div>
</template>
Componente LangSpan para Vue
<!-- components/LangSpan.vue -->
<script setup lang="ts">
import { computed } from 'vue'
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
interface Props {
lang: string
tag?: 'span' | 'div' | 'section' | 'article' | 'blockquote' | 'p'
}
const props = withDefaults(defineProps<Props>(), {
tag: 'span'
})
const isRtl = computed(() =>
RTL_LANGUAGES.some(l => props.lang.startsWith(l))
)
const direction = computed(() => isRtl.value ? 'rtl' : undefined)
</script>
<template>
<component
:is="tag"
:lang="lang"
:dir="direction"
>
<slot />
</component>
</template>
<!-- Uso -->
<LangSpan lang="de">Kindergarten</LangSpan>
<LangSpan lang="ja" tag="blockquote">
<p>日本語のテキスト</p>
</LangSpan>
CSS para Estilos Específicos por Idioma
/* Selección de fuentes base por idioma */
:lang(es) {
font-family: 'Inter', system-ui, sans-serif;
}
:lang(zh) {
font-family: 'Noto Sans SC', 'PingFang SC', sans-serif;
}
:lang(ja) {
font-family: 'Noto Sans JP', 'Hiragino Sans', sans-serif;
}
:lang(ko) {
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', sans-serif;
}
:lang(ar) {
font-family: 'Noto Sans Arabic', 'Tahoma', sans-serif;
}
/* Soporte para idiomas RTL */
:lang(ar),
:lang(he),
:lang(fa),
:lang(ur) {
direction: rtl;
text-align: right;
}
/* Comillas por idioma */
:lang(es) q {
quotes: '«' '»' '"' '"';
}
:lang(en) q {
quotes: '"' '"' "'" "'";
}
:lang(fr) q {
quotes: '« ' ' »' '‹ ' ' ›';
}
:lang(de) q {
quotes: '„' '"' '‚' ''';
}
:lang(ja) q {
quotes: '「' '」' '『' '』';
}
/* Estilo de frases extranjeras en línea */
[lang]:not(:lang(es)) {
/* Indicación sutil de cambio de idioma */
font-style: italic;
}
/* Excepción: No italizar cambios de idioma a nivel de bloque */
section[lang],
article[lang],
blockquote[lang],
div[lang] {
font-style: normal;
}
/* Aislamiento de texto bidireccional */
[lang] {
unicode-bidi: isolate;
}
/* Separación silábica por idioma */
:lang(es) {
hyphens: auto;
-webkit-hyphens: auto;
}
:lang(de) {
/* El alemán tiene reglas estrictas de separación silábica */
hyphens: auto;
-webkit-hyphens: auto;
}
:lang(zh),
:lang(ja),
:lang(ko) {
/* Los idiomas CJK no usan separación silábica */
hyphens: none;
word-break: keep-all;
}
Detección de Idioma del Lado del Servidor
Implementación Next.js/Node.js
// lib/language.ts
import { NextRequest } from 'next/server';
const SUPPORTED_LANGUAGES = ['es', 'en', 'fr', 'de', 'ar'];
const DEFAULT_LANGUAGE = 'es';
export function detectLanguage(request: NextRequest): string {
// 1. Verificar parámetro de URL
const urlLang = request.nextUrl.searchParams.get('lang');
if (urlLang && SUPPORTED_LANGUAGES.includes(urlLang)) {
return urlLang;
}
// 2. Verificar prefijo de ruta
const pathLang = request.nextUrl.pathname.split('/')[1];
if (SUPPORTED_LANGUAGES.includes(pathLang)) {
return pathLang;
}
// 3. Verificar cookie
const cookieLang = request.cookies.get('preferred-language')?.value;
if (cookieLang && SUPPORTED_LANGUAGES.includes(cookieLang)) {
return cookieLang;
}
// 4. Verificar encabezado Accept-Language
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const languages = acceptLanguage
.split(',')
.map(lang => {
const [code, q = '1'] = lang.trim().split(';q=');
return { code: code.split('-')[0], quality: parseFloat(q) };
})
.sort((a, b) => b.quality - a.quality);
for (const { code } of languages) {
if (SUPPORTED_LANGUAGES.includes(code)) {
return code;
}
}
}
// 5. Por defecto
return DEFAULT_LANGUAGE;
}
// middleware.ts
export function middleware(request: NextRequest) {
const language = detectLanguage(request);
// Establecer encabezado de idioma para SSR
const response = NextResponse.next();
response.headers.set('x-detected-language', language);
return response;
}
Pruebas Automatizadas
Tests de Playwright para Atributos de Idioma
import { test, expect } from '@playwright/test';
test.describe('Atributos de Idioma', () => {
test('la página tiene atributo lang válido en elemento html', async ({ page }) => {
await page.goto('/');
// Obtener atributo lang del elemento html
const lang = await page.locator('html').getAttribute('lang');
// Debe existir
expect(lang).not.toBeNull();
expect(lang).not.toBe('');
// Debe tener formato BCP 47 válido (verificación básica)
expect(lang).toMatch(/^[a-z]{2,3}(-[A-Za-z]{2,4})?(-[A-Za-z]{2})?$/);
});
test('el atributo lang coincide con el idioma del contenido de la página', async ({ page }) => {
// Probar página en español
await page.goto('/es/nosotros');
const esLang = await page.locator('html').getAttribute('lang');
expect(esLang).toMatch(/^es/);
// Probar página en inglés
await page.goto('/en/about');
const enLang = await page.locator('html').getAttribute('lang');
expect(enLang).toMatch(/^en/);
});
test('las frases extranjeras tienen atributos lang', async ({ page }) => {
await page.goto('/contenido-multilingue');
// Encontrar spans/elementos con contenido extranjero
const foreignPhrases = page.locator('[lang]:not(html)');
const count = await foreignPhrases.count();
// Si la página tiene contenido extranjero, debe estar marcado
if (count > 0) {
for (let i = 0; i < count; i++) {
const lang = await foreignPhrases.nth(i).getAttribute('lang');
expect(lang).not.toBeNull();
expect(lang).toMatch(/^[a-z]{2,3}/);
}
}
});
test('los idiomas RTL tienen atributo dir correcto', async ({ page }) => {
await page.goto('/ar/about');
// HTML debe tener dir="rtl" para árabe
const dir = await page.locator('html').getAttribute('dir');
expect(dir).toBe('rtl');
// El contenido árabe en línea también debe tener dir
const arabicContent = page.locator('[lang^="ar"]');
const count = await arabicContent.count();
for (let i = 0; i < count; i++) {
const element = arabicContent.nth(i);
const isBlock = await element.evaluate(el =>
['DIV', 'SECTION', 'ARTICLE', 'BLOCKQUOTE'].includes(el.tagName)
);
if (isBlock) {
const dir = await element.getAttribute('dir');
expect(dir).toBe('rtl');
}
}
});
test('los atributos hreflang están presentes para idiomas alternativos', async ({ page }) => {
await page.goto('/es/nosotros');
// Verificar enlaces de idiomas alternativos
const alternateLinks = page.locator('link[rel="alternate"][hreflang]');
const count = await alternateLinks.count();
// Debe tener al menos 2 (propio + uno alternativo)
expect(count).toBeGreaterThanOrEqual(2);
// Cada uno debe tener hreflang válido
for (let i = 0; i < count; i++) {
const hreflang = await alternateLinks.nth(i).getAttribute('hreflang');
expect(hreflang).toMatch(/^[a-z]{2,3}(-[A-Za-z]{2})?$|^x-default$/);
}
});
test('el selector de idioma cambia el atributo lang', async ({ page }) => {
await page.goto('/es/nosotros');
// Verificar idioma inicial
let lang = await page.locator('html').getAttribute('lang');
expect(lang).toMatch(/^es/);
// Hacer clic en selector de idioma
await page.click('[data-testid="lang-switch-en"]');
// Esperar navegación
await page.waitForURL(/\/en\//);
// Verificar que el idioma cambió
lang = await page.locator('html').getAttribute('lang');
expect(lang).toMatch(/^en/);
});
});
Auditoría de Accesibilidad para Idioma
// language-audit.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Auditoría de Accesibilidad de Idioma', () => {
test('pasa las reglas de idioma de axe', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules([
'html-has-lang', // elemento html debe tener lang
'html-lang-valid', // lang debe ser válido
'html-xml-lang-mismatch', // lang y xml:lang deben coincidir
'valid-lang' // atributos lang deben ser válidos
])
.analyze();
expect(results.violations).toHaveLength(0);
});
test('todas las páginas tienen declaración de idioma consistente', async ({ page }) => {
const pages = ['/es/', '/es/nosotros', '/es/servicios', '/es/contacto'];
for (const url of pages) {
await page.goto(url);
const lang = await page.locator('html').getAttribute('lang');
expect(lang).toMatch(/^es/);
// Ejecutar axe en cada página
const results = await new AxeBuilder({ page })
.withRules(['html-has-lang', 'html-lang-valid'])
.analyze();
expect(results.violations).toHaveLength(0);
}
});
});
Checklist de Resumen
- [ ] Cada página tiene atributo
langen el elemento<html> - [ ] Los códigos de idioma siguen el formato BCP 47
- [ ] El idioma coincide con el contenido real de la página
- [ ] Las frases extranjeras tienen atributos
langapropiados - [ ] Los idiomas RTL tienen atributo
dir="rtl" - [ ] CSS proporciona configuraciones tipográficas específicas por idioma
- [ ] Las versiones en idiomas alternativos tienen enlaces
hreflang - [ ] La detección de idioma usa una cadena de respaldo apropiada
- [ ] Los componentes soportan cambio dinámico de idioma
- [ ] Las pruebas automatizadas verifican los atributos de idioma