Guía detallada

Language Attributes Implementation Guide

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 lang en 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 lang apropiados
  • [ ] 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

Referencias

  1. W3C - WCAG 2.2 SC 3.1.1 Language of Page
  2. W3C - WCAG 2.2 SC 3.1.2 Language of Parts
  3. W3C - H57 Using lang attribute
  4. IETF - BCP 47: Tags for Identifying Languages
  5. W3C - Choosing language tags

Artículos relacionados

Versión relacionada

Introducción

Language Attributes Explained

El atributo de HTML indica a los navegadores y tecnologías asistivas en qué idioma humano está tu contenido

Hub de categoría

Hub

Wcag Compliance Hub

La accesibilidad web asegura que los sitios web y aplicaciones puedan ser utilizados por todos, incluyendo personas con discapacidades