guias

Section headings: jerarquía visual y SEO Astro

Cómo diseñar section headings que enganchen sin romper la jerarquía H1-H6 ni la legibilidad para buscadores, con ejemplos de Astro y tokens CSS.

Section headings: jerarquía visual y SEO Astro

A las dos semanas de lanzar la primera versión de ejemplos.mx audité el sitio con la extensión HeadingsMap de Chrome y descubrí un árbol semántico que parecía editado por dos personas distintas. La home tenía un H1 correcto, pero la sección «¿Qué es esta plantilla?» abría con otro H1 «porque pesa más para SEO» (escrito por mí, semanas antes, en un viernes con prisa). La página de servicios saltaba de H2 directamente a H4 porque el diseñador había decidido que H3 «se veía gordo». El blog tenía tres H1 distintos por artículo: uno del título, uno del eyebrow marcado como heading «para resaltarlo», y uno escondido dentro de un sidebar. Visualmente la página se veía cuidada — los tamaños y pesos eran consistentes con el diseño — pero el árbol del documento era un caos. VoiceOver en iPhone leía la home en cuatro saltos de jerarquía mal anidados. Una persona ciega que intentara navegar por encabezados se encontraba con sub-secciones huérfanas en cada bloque.

Ese fue el momento en que escribimos el componente SectionHeading con as cableado en el tipo TypeScript, y la regla canónica del proyecto: cero ‹h2› sueltos en .astro, todo título de sección pasa por el componente, defaults inmutables. Cuatro semanas después de la refactorización, Lighthouse pasó la auditoría de heading order de 64 puntos a 100 en todas las páginas y el primer reporte de Search Console marcó cero advertencias estructurales por primera vez. Esta guía es la receta que aplicamos, con los porqués técnicos detrás de cada decisión, el detalle del componente que cierra los tres errores frecuentes —dos H1 hermanos, saltos de nivel, eyebrow marcado como heading— y los edge cases que descubrimos en producción.

Un sitio con encabezados desordenados se ve bonito en la PC del diseñador y mal en todos lados al mismo tiempo: el buscador no lo entiende, el lector de pantalla salta de H2 a H4 sin avisar y el visitante escanea el muro de párrafos sin pistas. El section heading es la pieza que vuelve esa página legible para personas y previsible para máquinas. Esta guía explica cómo se diseña uno que cumpla ambos contratos sin negociar uno por el otro.

Por qué este patrón existe

La especificación HTML4 (1999) introdujo los encabezados H1-H6 como árbol semántico, no como decoración tipográfica. HTML5 (2014) intentó relajar la regla del «único H1 por página» con el outline algorithm —cada ‹section› podía tener su propio H1 dentro—, pero el algoritmo nunca se implementó en ningún navegador ni en Googlebot. En 2022 la W3C oficialmente lo deprecó: la guía actual de WAI-ARIA Authoring Practices y de WCAG 2.2 SC 1.3.1 (Info and Relationships) vuelven a la regla original: un H1 por página, niveles escalonados sin saltos.

La razón por la que la regla aguanta dos décadas es que tres consumidores distintos dependen del mismo árbol. El primero es el buscador: Google parsea el HTML y construye un árbol semántico que usa para identificar el tema principal de la página (el H1), las sub-secciones (H2) y los matices (H3). Dos H1 hermanos diluyen la señal del tema principal, y aunque Google «no penalice» en el sentido estricto, sí dificulta el ranking por la keyword principal. El segundo consumidor es el lector de pantalla: VoiceOver, NVDA, JAWS y TalkBack permiten al usuario navegar página por encabezados con una sola tecla (H en NVDA, gesto rotor en VoiceOver). Si saltas de H2 a H4 sin pasar por H3, la persona ciega percibe una sub-sección huérfana y no sabe a qué bloque pertenece. El tercer consumidor son las herramientas de tabla de contenidos (TOC) automáticas — Astro tiene getHeadings() que extrae el árbol de un MDX para construir TOCs; si el árbol está mal anidado, la TOC tampoco tendrá sentido.

Contexto

La regla de oro para SEO técnico no cambió en los últimos diez años: un H1 por página y los siguientes niveles escalonados sin saltos, donde cada ‹h2› abre una sección del cuerpo y los ‹h3› cuelgan de su ‹h2› padre. La tentación de meter “otro H1 porque pesa más en la búsqueda” sigue circulando en foros, pero los crawlers modernos —Googlebot incluido— interpretan la jerarquía como árbol semántico: dos H1 hermanos confunden el modelo del documento y diluyen la señal de tema principal.

El problema no es solo de SEO. La accesibilidad usa exactamente el mismo árbol. Un lector de pantalla como VoiceOver o NVDA permite navegar página por encabezados con una sola tecla; si saltas de H2 a H4 sin pasar por H3, la persona ciega percibe una sub-sección huérfana y no sabe a qué bloque pertenece. La regla “no saltes niveles” no es cosmética: es la diferencia entre una página navegable y una que solo se puede leer en orden lineal.

A esto se suma una capa visual. El section heading no es solo un ‹h2› a secas: es un patrón compuesto por eyebrow (rótulo corto en mayúsculas), título (la promesa del bloque) y descripción (1-2 frases que amplían). El eyebrow no debe ser un heading HTML —no aporta nivel jerárquico, es metadata visual— y el accent (segunda línea de color) tampoco debe romper el título en dos elementos semánticos. La trampa común es marcar el eyebrow como ‹h3› “para que se vea más importante”: rompe la jerarquía y mete una sub-sección fantasma en el árbol del documento.

Implementación paso a paso

El componente SectionHeading.astro de esta plantilla resuelve estas tres trampas a la vez. Vale la pena leerlo antes de copiar nada: la regla canónica está cableada en los defaults, no en la documentación.

---
// src/components/SectionHeading.astro
export interface Props {
  id?: string
  eyebrow?: string
  /** Primera linea del titulo (obligatoria). */
  title: string
  /** Segunda linea, resaltada con color de acento. */
  titleAccent?: string
  desc?: string
  layout?: 'simple' | 'duo'
  align?: 'center' | 'left'
  body?: string[]
  dark?: boolean
  /** Etiqueta semantica del encabezado de bloque. */
  as?: 'h2' | 'h3'
}
const { id, eyebrow, title, titleAccent, desc, layout = 'simple',
        align = 'center', body = [], dark = false, as = 'h2' } = Astro.props
const Tag = as
---

La prop as con default 'h2' es la primera línea de defensa: a menos que se pase as="h3" explícitamente, cada SectionHeading emite un ‹h2›. Imposible meter sin querer un segundo H1 (no existe as="h1" en el tipo) o saltar de H2 a H4 (los únicos valores válidos son h2 y h3). La jerarquía está cableada en el tipo de TypeScript.

El segundo punto es cómo se construye el título cuando hay acento. La tentación es partir la frase en dos elementos —un ‹h2› para la primera línea y un ‹p› o ‹span› para la segunda—. El componente resuelve diferente:

<Tag class="sech__title" id={id}>
  {title}{titleAccent && <><br /><span class="sech__accent">{titleAccent}</span></>}
</Tag>

Un solo ‹h2› con un ‹br› y un ‹span› dentro. El buscador lee el texto completo como un solo heading; los lectores de pantalla anuncian la frase entera sin interrupciones; el CSS aplica el color de marca al .sech__accent sin pedir un elemento semántico extra. El accent es decoración visual sobre un heading único, no un heading partido en dos.

El tercer punto, el eyebrow, sigue la misma disciplina:

{eyebrow && <p class="sech__eyebrow"><span class="sech__eyebrow-bar" aria-hidden="true"></span>{eyebrow}</p>}

Un ‹p› —no un heading— con una barra de acento marcada como aria-hidden="true" para que los lectores de pantalla no la anuncien como “decoración”. El eyebrow es texto plano marcado como párrafo, estilizado como rótulo en mayúsculas via CSS:

.sech__eyebrow {
  font-size: var(--text-xs, .8125rem);
  font-weight: var(--weight-bold, 700);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--color-red, var(--c-primary, #C41E24));
}

text-transform: uppercase deja el contenido del DOM en minúsculas y solo lo pinta en mayúsculas: los buscadores ven la palabra escrita normalmente (mejor para indexación), el copywriter escribe sin gritar y el visitante percibe el rótulo en ALL CAPS. Tres ganancias por una propiedad CSS.

Tabla comparativa

Decisión semánticaPatrón correctoAnti-patrón frecuente
H1 por páginaUno solo, en el heroUn H1 por sección “para reforzar SEO”
Eyebrow del section heading‹p› con text-transform: uppercase‹h3› o ‹h4› “para que pese más”
Título con acento de colorUn ‹h2› con ‹span› internoDos elementos (‹h2› + ‹p›) hermanos
Sub-sección dentro de H2‹h3› via prop as="h3"‹h4› o ‹div class="titulo"›
Barra de acento decorativaaria-hidden="true"Sin atributo (la lee VoiceOver)
Mayúsculas del eyebrowCSS text-transformEscrita en mayúsculas en el MDX
Descripción del bloque‹p› debajo del títuloOtro ‹h2› “porque también es importante”
Anchor para deep-linkProp id que se aplica al heading‹a name› antes del heading

Tabla de impacto: el árbol semántico medido en Lighthouse y a11y

Métrica / consumidorSin árbol correctoCon árbol correcto
Lighthouse 12.x heading order60-80 puntos100 puntos
WCAG 2.2 SC 1.3.1 conformanceFallaCumple
Tiempo VoiceOver para recorrer la página por encabezadosImposible (estructura rota)8-15 s en una landing típica
Astro getHeadings() (TOC automática)Indentación erráticaAnidación correcta
Google sitelinks en SERPProbabilidad bajaProbabilidad alta para keywords de cola
HeadingsMap visual (Chrome ext)Saltos rojos visiblesÁrbol verde, escalonado

Lighthouse es la herramienta más rápida para detectar el problema: en la categoría «Accessibility», la auditoría «Heading elements appear in a sequentially-descending order» se vuelve roja si hay un solo salto. Para auditoría manual con datos, la extensión HeadingsMap renderiza el árbol del documento como sidebar y marca los saltos en rojo.

Patrones avanzados

Accent como destacado semántico, no como dos H2. El error más común con la segunda línea resaltada es marcarla como un encabezado separado para que el color “se vea”. El componente la mantiene dentro del mismo ‹h2›, así que tanto el texto plano del SEO como el flujo del lector de pantalla la perciben como una única promesa. La regla práctica: si retiras todos los estilos CSS, el HTML debe seguir contando una historia legible —un solo encabezado con una frase completa, no dos fragmentos colgando—. Si el contenido se rompe sin CSS, el HTML está mal escrito.

Eyebrow ALL CAPS por CSS, no por copia. Escribir “DEFINICIÓN” en el MDX provoca tres efectos perversos: los buscadores que normalizan mayúsculas para indexar pierden la palabra clave, los lectores de pantalla algunos las deletrean letra por letra (“D-E-F-I-N-I-C-I-Ó-N”) y el copywriter siente que está gritando. Escribir “Definición” en el MDX y dejar que text-transform: uppercase haga el trabajo visual evita los tres. Es la misma razón por la que los logos en minúsculas escriben “ejemplos.mx” y no “EJEMPLOS.MX” en el alt de la imagen.

Balance visual del duo + ancho de medida en la descripción. El layout duo enfrenta dos columnas: título a la izquierda, body a la derecha. La descripción del título tiene un max-width: 60ch en simple y max-width: none en duo —porque en duo el ancho ya lo da el grid, no la propia descripción—. Si en duo dejas el 60ch, la columna izquierda queda con un párrafo flaco al lado de un grid generoso a la derecha y el balance se rompe. La regla está en el CSS scoped:

.sech--duo .sech__desc { max-width: none; }

Es un detalle de tres palabras que decide si la página se siente cuidada o rota.

Dark variant con contraste AA en el body, no solo en el título. Pasar dark cambia el color del .sech__title a #fff (contraste excelente sobre fondo negro), pero el .sech__desc y el .sech__body-p bajan a rgba(255,255,255,.75). Ese 75 % de opacidad sobre fondo #111 da un contraste de 9.2:1 —muy por encima del 4.5:1 exigido por WCAG AA para texto normal—. Si quisieras bajar más la opacidad para “que el cuerpo se sienta secundario”, revisa en una herramienta de contraste antes de empujar a producción: a 50 % de opacidad el ratio cae a 5.1:1 (todavía AA), pero a 35 % cae a 3.4:1 (falla incluso para texto grande).

Edge cases y debugging

MDX-generated headings que rompen el árbol del layout. En artículos .mdx el autor escribe ## Sub-sección y ### Detalle libremente. El problema es que el artículo vive dentro de un ‹PageLayout› que ya emitió un ‹h1› (el título del artículo) y posiblemente un ‹h2› (un section heading del módulo). Si el primer ## del MDX se renderiza como ‹h2›, queda hermano del section heading del layout — semánticamente válido. Pero si el autor escribe # Algo (un ‹h1›), aparece un segundo H1 en la página y el árbol se rompe. La defensa: configurar rehypeAutolinkHeadings para que falle el build si detecta un H1 dentro del cuerpo MDX, o documentar en docs/MDX-CONVENTIONS.md que el primer nivel de heading dentro de un artículo es ## (H2). En ejemplos.mx usamos un linter remark-lint-no-duplicate-headings que advierte en build.

Eyebrow con keywords muy largas que se cortan. El .sech__eyebrow tiene text-transform: uppercase con letter-spacing: .08em. En móvil (≤480 px) una palabra de 14 caracteres en mayúsculas con letter-spacing ocupa toda la columna y a veces se parte en dos líneas. La solución no es bajar el font-size (afecta el rol de rótulo), sino acortar el copy. Si el eyebrow dice «DEFINICIÓN TÉCNICA AMPLIADA», cámbialo por «DEFINICIÓN»; si dice «PREGUNTAS FRECUENTES DEL MÓDULO», cámbialo por «FAQ». El eyebrow es un rótulo, no un párrafo.

Sub-secciones dentro de un componente que ya abre un ‹h2›. Si un componente como ‹CategoryDetail› emite su propio ‹h2› (el título del bloque), y dentro del body de ese bloque hay otra sección con ‹SectionHeading›, esa segunda sección debe usar as="h3". El error frecuente es olvidar el as y dejar el default h2, terminando con dos ‹h2› hermanos cuando uno debería ser hijo del otro. La regla práctica: cada vez que anidas un SectionHeading dentro de otro componente que ya tiene heading, mira primero el nivel del padre y baja uno.

Anclajes (id) con caracteres especiales. El componente acepta id="sub-seccion" directo, pero si el copy del título tiene acentos, espacios o caracteres especiales y los usas como base para el id, los anchors pueden romperse en navegadores antiguos. La regla: el id siempre es ASCII puro, kebab-case, sin acentos (mi-seccion, no mi-sección). Si necesitas generar el id automáticamente desde el title, normaliza con String.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').

Performance y a11y con números reales

El componente SectionHeading.astro pesa 980 bytes minificado más 1.4 KB de CSS scoped (que se carga una sola vez por página, no por instancia). En transferencia gzip, eso son 380 + 520 = 900 bytes por página independientemente de cuántos section headings haya. En benchmarks medidos con Lighthouse 12.x sobre /modulos/section-heading en un Pixel 5 con throttling Slow 4G, una página con seis instancias del componente:

MétricaSolo ‹h2› sueltosCon SectionHeading (×6)
LCP1.71 s1.74 s
INP88 ms90 ms
CLS0.010.01
HTML size (gzip)5.9 KB6.4 KB
Lighthouse a11y score92100

El delta de 500 bytes de HTML es el costo del markup extra (eyebrow, body, descripción). El delta de a11y de +8 puntos viene de tres correcciones automáticas: contraste correcto del eyebrow, jerarquía sin saltos, y aria-hidden en la barra decorativa.

La conformancia WCAG 2.2 cubre cuatro criterios. SC 1.3.1 (Info and Relationships) por la jerarquía sin saltos. SC 1.4.3 (Contrast) por el var(--c-ink) sobre fondo blanco (ratio 16:1) y var(--text-muted) para descripciones (ratio 7.5:1). SC 2.4.6 (Headings and Labels) por copy descriptivo (el componente no permite títulos vacíos — la prop title es requerida en TypeScript). SC 1.4.10 (Reflow) por el ancho fluido sin scroll horizontal en 320 px.

Casos donde NO usar SectionHeading

ContextoPor qué evitar el componenteAlternativa
Hero principal de la páginaEl hero necesita su propio ‹h1› y CTAs propios‹HeroSection› con su ‹h1›
Título del artículo (.mdx)El layout lo emite desde frontmatter.title‹PageLayout› lo gestiona
Sub-título dentro de una tarjetaEl ‹h3› cabe directo, sin eyebrow ni body‹h3› plano en el componente de tarjeta
Pre-footer CTA tipo «Listo?»Necesita un patrón distinto (botón gigante, fondo de marca)‹CtaBanner› con su tipografía
Sección sin título visibleSi la sección no necesita título, no lo metas a la fuerzaOmite el componente y usa ‹section aria-label›

La tentación es pasar todo título por SectionHeading «por consistencia». El sitio se vuelve repetitivo. El componente es para abrir bloques de contenido narrativo dentro de páginas internas; las excepciones tienen su propio patrón.

Checklist

  • Un solo ‹h1› por página, ubicado en el hero.
  • Todos los títulos de sección usan SectionHeading (cero ‹h2› sueltos en .astro).
  • El eyebrow es un ‹p› con text-transform: uppercase por CSS, no escrito en mayúsculas.
  • El titleAccent se renderiza dentro del mismo ‹h2›, no como elemento hermano.
  • La barra decorativa del eyebrow lleva aria-hidden="true".
  • Las sub-secciones usan as="h3" (no se inventan ‹h4› saltando un nivel).
  • En layout="duo" la descripción no lleva max-width en ch (lo controla el grid).
  • En dark, el contraste de texto sobre fondo cumple ≥ 4.5:1 (verificado).
  • Lighthouse 12.x marca 100 en «Heading elements appear in a sequentially-descending order».
  • HeadingsMap (Chrome ext) no muestra ningún salto rojo en el árbol de la página.
  • El eyebrow tiene 1-2 palabras (no «PREGUNTAS FRECUENTES DEL MÓDULO»).
  • El id del heading, si lo pasas, es ASCII puro kebab-case (sin acentos ni espacios).
  • Si el componente se anida dentro de otro que ya emite ‹h2›, se pasa as="h3".

Preguntas frecuentes

¿Por qué no usar ‹h1› en cada sección si Google “ahora soporta varios H1”? Soportar no es premiar. Google parsea sin reventar una página con varios H1, pero el modelo de documento sigue siendo un árbol con un tema principal. Dos H1 hermanos compiten por esa señal y bajan la claridad del tópico. La regla “un H1 por página” sigue siendo el consenso entre SEO técnico y accesibilidad, y el costo de cumplirla es cero.

¿El eyebrow afecta el SEO si tiene palabras clave? Sí, pero como texto plano dentro de un ‹p› —no como heading—. Los buscadores lo indexan como cualquier párrafo: aporta contexto léxico al bloque sin competir con el ‹h2›. La práctica de meter palabras clave repetidas en el eyebrow es contraproducente: si “Definición · Plomería en Polanco · Servicios” aparece arriba de cada sección, el sitio se ve spam y el lector de pantalla anuncia tres rótulos antes del título.

¿Cómo decido entre as="h2" y as="h3"? Pregunta de árbol: si el bloque está al primer nivel del cuerpo (debajo del hero ‹h1›), va h2. Si está dentro de una sección que ya abrió con un h2, sus sub-bloques van h3. Más allá de h3h4, h5— casi nunca es necesario: si tu documento exige cinco niveles de profundidad, probablemente la página debería partirse en dos URLs.

¿Puedo poner un anchor (#mi-seccion) en el título? Sí, vía la prop id: el componente la aplica directamente al ‹h2› (‹Tag class="sech__title" id=❴id❵›). Esto habilita deep-linking (#mi-seccion desde una tabla de contenidos) y evita el anti-patrón de meter un ‹a name="..."› vacío arriba del heading, que ensucia el DOM.

¿El ‹br› dentro del título no es semánticamente sucio? En este caso no. El ‹br› se usa porque la frase es una sola promesa visualmente partida en dos líneas: el HTML5 admite ‹br› cuando el salto es parte del contenido (poesía, direcciones, títulos de doble línea). Lo que sería sucio es usar ‹br›‹br› para simular un párrafo, o partir un párrafo largo en varios ‹br› en vez de usar ‹p›.

¿Cómo se comporta SectionHeading con view transitions de Astro 6? Por defecto se reemplaza junto con el resto del contenido. Si quieres animar el salto entre páginas (un fade del título viejo al nuevo), añade transition:name="hero-title" al componente —usando el mismo name en ambas páginas— y Astro aplicará la transición compartida. La trampa: si dos componentes en distintas páginas comparten transition:name, deben tener la misma jerarquía semántica (h1 con h1, no h1 con h2); si no, el árbol se vuelve inconsistente durante la transición.

¿Puedo usar SectionHeading para abrir secciones dentro de un MDX? Sí, importándolo: import SectionHeading from '@components/SectionHeading.astro' en el frontmatter del MDX y usándolo como cualquier componente. La ventaja es consistencia visual con el resto del sitio; la desventaja es que rompe el modelo de Diátaxis si lo abusas en tutoriales (un tutorial debe ser markdown puro, leíble fuera del navegador). En ejemplos.mx lo usamos en artículos editoriales como este, pero no en guías paso a paso donde el lector copia el markdown.

¿Cómo manejo títulos en idiomas con caracteres no-latinos (chino, japonés, árabe)? El componente acepta cualquier string Unicode en title y eyebrow. La trampa es el letter-spacing: .08em del eyebrow: en chino o japonés, ese espaciado se ve raro porque los caracteres ya son monoespaciados visualmente. Si el sitio es multilingüe, condiciona el letter-spacing por lang: [lang="zh"] .sech__eyebrow ❴ letter-spacing: 0; ❵. Para árabe (RTL), el componente respeta direction: rtl del ‹html› siempre que el CSS del proyecto no fuerce direction: ltr en alguna regla.


El section heading bien construido es una pieza pequeña que decide cosas grandes: si la página rankea por el tema que esperabas, si una persona ciega la puede recorrer en treinta segundos, si el visitante se queda a leer o cierra la pestaña. La buena noticia es que el patrón cabe en un solo componente y se obedece con dos disciplinas: nunca saltar niveles de heading y nunca convertir decoración visual en estructura semántica. Cuando esas dos invariantes se mantienen en código (no en comentarios), el sitio escala a 100 páginas sin perder coherencia y el equipo de tres personas no tiene que pelearse en cada code review.

Sigue leyendo

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?