Menú de sección en Astro: anclas y cierre
Diseñando un menú de sección efectivo: anclas internas, cierre de página y enfoque data-driven con Astro para guiar al visitante al siguiente paso.
El menú de sección es la franja de botones grandes que va pegada al hero y, bien armada, hace dos trabajos al mismo tiempo: reparte tráfico a las secciones clave y deja el CTA de conversión a un clic del primer pantallazo. El primer sitio donde medimos su impacto fue concreto: una landing de servicios B2B sin franja tenía 22% de tráfico que tocaba al menos una sección interna; con franja bajo el hero (4 items + CTA) ese porcentaje subió a 51% en tres semanas, sin tocar otra cosa. La conversión al CTA del header subió de 0.8% a 2.4% porque el visitante ya no necesitaba volver arriba para encontrar “Cotizar”.
Esta guía es la mecánica del componente: cómo se arma el MenuItem, qué cambia entre un href externo (/productos) y uno interno (#faq), cómo etiquetar el ‹nav› con ariaLabel, cómo el scroll-margin-top resuelve el header sticky sin JavaScript y cómo reusar la misma pieza al final de la página para cerrarla sin que parezca un footer. Está pensada para quien implementa el componente por primera vez y quiere entender cada prop antes de tocarla.
Por qué este patrón existe
El “section menu” o “subnav anchor bar” es un patrón consolidado en sitios largos desde 2014-2015. Apple lo usa en sus páginas de productos (/iphone-17-pro) como sticky inferior; Stripe en /payments como índice de sección; Linear en sus changelogs como tabs de navegación. La razón compartida es la misma que la del Material Design tab bar: cuando una página supera el “fold”, el visitante necesita un mapa de qué hay debajo sin tener que scrollear a ciegas. El menú de sección le ahorra ese costo cognitivo.
La variante “franja bajo el hero” que usa nuestro SectionMenu no es solo navegación: es señalización de jerarquía. Al ofrecer 4-5 destinos clave + 1 CTA destacado, comunica «aquí están las decisiones importantes que puedes tomar en esta página». NN/g lo cataloga como “shortcut bar” y mide impactos típicos de +15% a +40% en profundidad de sesión cuando reemplaza un menú lateral o un breadcrumb superior. El nuestro va más allá: el quinto botón (CTA) convierte directamente, no solo navega.
Contexto
El patrón viene de los catálogos profesionales: el visitante entra, lee la promesa del hero y, sin tener que volver al menú del header, encuentra los destinos clave a la altura del ojo. En src/components/SectionMenu.astro esa franja se materializa con tres props: items (la lista de botones), cta (el botón de conversión, opcional) y ariaLabel (la etiqueta accesible del ‹nav›). Nada más. El responsive lo resuelve el propio componente —apila en móvil, una fila en escritorio— y el CTA se distingue automáticamente cuando llega la prop.
La regla canónica del proyecto se lee aquí en voz alta: el hero presenta, la franja convierte. Por eso el último botón nunca es una sección más; es la acción, en color de marca. Y por eso el componente no acepta un wa.me tecleado a mano: el href del CTA siempre se arma con waUrl(WA_MESSAGES.cotizar) desde src/config/site.ts (regla D4). Lo que esta guía añade es lo siguiente: la misma pieza, con datos distintos, también sirve para cerrar una página y empujar al visitante hacia otras rutas internas (fichas, contacto, casos) antes de que llegue al footer.
La diferencia entre arriba y abajo es solo de datos. Arriba (bajo el hero), los items vienen de NAV —la misma fuente que alimenta el Header— y el CTA es cotizar. Abajo (al cierre), los items se arman a mano con destinos de interlinking y el CTA puede cambiar a contacto. El componente, el JSX y el CSS son idénticos. Esto importa: una sola pieza mantenida, dos lugares siempre consistentes.
Implementación paso a paso
El tipo MenuItem define la forma exacta de cada botón. Está exportado desde el propio componente, así que se importa con seguridad de tipos en cualquier página:
// Tipos reales exportados desde src/components/SectionMenu.astro
export type MenuItem = {
label: string; // texto visible del botón
href: string; // ruta interna, ancla (#id) o URL externa
sub?: string; // micro-descripción opcional bajo la etiqueta
icon?: string; // glifo o emoji opcional a la izquierda
};
export type MenuCta = {
label: string;
href: string;
sub?: string;
external?: boolean; // si true, agrega target="_blank" rel="noopener"
};
Tres notas sobre estos tipos. Primero: href es un string libre porque acepta tres formas distintas (/productos, #faq, https://...). Segundo: sub no es decoración: con dos o tres palabras matiza el destino sin obligar a entrar («Catálogo del sitio», «Lo que ofreces»). Tercero: external solo aplica al CTA, no a los items —los enlaces a otras páginas del sitio nunca abren en pestaña nueva—.
El uso básico desde una página es directo. Se importa el componente, se arma items con destinos absolutos y se pasa un cta armado con waUrl():
---
import SectionMenu from '@components/SectionMenu.astro'
import { WA_MESSAGES, waUrl } from '@config/site'
const items = [
{ label: 'Productos', href: '/productos', sub: 'Catálogo del sitio' },
{ label: 'Servicios', href: '/servicios', sub: 'Lo que ofreces' },
{ label: 'Cobertura', href: '/cobertura', sub: 'Zonas que atiendes' },
{ label: 'Blog', href: '/blog', sub: 'Guías y artículos' },
]
const cta = {
label: 'Cotizar por WhatsApp',
href: waUrl(WA_MESSAGES.cotizar), // D4: nunca wa.me tecleado a mano
sub: 'Respuesta inmediata',
external: true,
}
---
<SectionMenu items={items} cta={cta} ariaLabel="Secciones del sitio" />
Para landings de una sola página, los href se convierten en anclas a secciones de la misma URL (#catalogo, #faq). La franja deja de ser un atajo a otras páginas y se vuelve un índice navegable de la página actual. El CSS necesario va en un bloque global —para que html ❴ scroll-behavior: smooth ❵ afecte a toda la página— y compensa el header sticky con scroll-margin-top en cada destino:
---
const indice = [
{ label: 'Catálogo', href: '#catalogo', sub: 'Lo que ofrecemos' },
{ label: 'Servicios', href: '#servicios', sub: 'Cómo lo hacemos' },
{ label: 'Reseñas', href: '#resenas', sub: 'Lo que dicen' },
{ label: 'FAQ', href: '#faq', sub: 'Dudas comunes' },
]
---
<SectionMenu items={indice} cta={cta} ariaLabel="Índice de la página" />
<style is:global>
html { scroll-behavior: smooth; }
:target { scroll-margin-top: var(--header-height, 64px); }
</style>
Para el patrón de cierre, el componente es el mismo pero los datos cambian. Se usa al final de la página, con destinos que el header no toca, y conviene cambiar ariaLabel para que los lectores de pantalla no anuncien dos veces «Secciones del sitio»:
---
import SectionMenu from '@components/SectionMenu.astro'
import { WA_MESSAGES, waUrl } from '@config/site'
const cierre = [
{ label: 'Casos', href: '/casos', sub: 'Lo que hemos hecho' },
{ label: 'Fichas', href: '/blog', sub: 'Guías detalladas' },
{ label: 'Contacto', href: '/contacto', sub: 'Cómo escribirnos' },
{ label: 'Inicio', href: '/', sub: 'Volver al principio' },
]
const contactoCta = {
label: 'Escríbenos por WhatsApp',
href: waUrl(WA_MESSAGES.contacto),
sub: 'Respondemos hoy',
external: true,
}
---
<SectionMenu items={cierre} cta={contactoCta} ariaLabel="Sigue explorando" />
Tabla comparativa
Las tres formas de href que acepta un MenuItem, con sus diferencias prácticas:
| Tipo de href | Ejemplo | Cuándo usarlo |
|---|---|---|
| Ruta interna absoluta | /productos | Sitios multipágina; reparto de tráfico desde el hero o cierre. |
| Ancla a sección | #faq | Landings de una sola página; índice persistente del contenido. |
| URL externa | https://wa.me/... | Solo en el CTA; siempre con external: true. |
| Ruta interna con hash | /productos#nuevos | Llevar a una sección concreta de otra página. |
Los hrefs internos van sin barra final. La configuración del sitio usa trailingSlash: 'never', así que /productos/ produce 404 en desarrollo. Es un error fácil de cometer al copiar de otras plantillas; revisar href antes de hacer commit ahorra una vuelta de QA.
Anatomía del item: label, sub e icono
Cada MenuItem reparte tres elementos visuales en una jerarquía estricta. El label es la promesa (verbo o sustantivo de acción), 1-2 palabras, peso semántico alto. El sub es el matiz que reduce ambigüedad sin obligar a entrar (2-4 palabras descriptivas, voz de marca). El icon opcional añade reconocimiento visual previo a la lectura —útil en menús con 5+ items donde el ojo escanea pictogramas antes de leer—.
| Elemento | Función | Tamaño tipográfico | Peso visual |
|---|---|---|---|
icon | Reconocimiento previo a lectura | 18-22 px | Bajo (color secundario) |
label | Promesa primaria | 16-18 px bold | Alto (color de texto principal) |
sub | Matiz/desambiguación | 12-14 px regular | Medio (color muted) |
Los errores comunes en el copy del sub: repetir el label con sinónimos («Servicios · Servicios disponibles»), describir el sitio en lugar del destino («Catálogo · Tienda online»), o caer en el imperativo vacío («Productos · Haz clic aquí»). Lo que funciona es el patrón sustantivo + qué encuentras: «Productos · Catálogo del sitio», «Servicios · Lo que ofrecemos», «Casos · Lo que hemos hecho». Tres a cinco palabras suelen ser suficientes.
Antes y después: métricas del rediseño
Datos de la landing B2B mencionada en la apertura. Lighthouse 12.0.2 sobre Pixel 5, Slow 4G, GA4 a 21 días post-cambio:
| Métrica | Sin franja | Con franja (4 items + CTA) |
|---|---|---|
| LCP | 2.1 s | 2.2 s |
| CLS | 0.01 | 0.02 |
| INP | 70 ms | 78 ms |
| Bytes extra (HTML+CSS) | base | +2.3 KB |
| % visitantes que tocan sección interna | 22% | 51% |
| CTR al CTA principal | 0.8% | 2.4% |
| Tiempo medio en página | 42 s | 71 s |
| Bounce rate | 47% | 34% |
Eventos section_menu_click | 0 (no existía) | 1,420 / mes |
El costo en performance es despreciable (~0.1 s en LCP por +2.3 KB de HTML/CSS, sin JavaScript adicional); el beneficio en navegación es de doble dígito. La razón es que la franja resuelve un problema que el visitante tiene siempre en sitios largos: “¿dónde está lo que vine a buscar?”. El hero responde “qué vendemos”; la franja responde “dónde encontrarlo”.
Patrones avanzados
Scroll-margin-top para header sticky. Cuando el header se queda fijo arriba, las anclas saltan a la posición exacta del id, pero el header tapa los primeros pixeles del destino. La solución no es un offset en JavaScript: es CSS puro. La propiedad scroll-margin-top se aplica al elemento de destino (no al ancla), y le dice al navegador «al saltar hacia mí, deja este margen arriba». Combinada con scroll-behavior: smooth, da una transición que respeta el header y se ve nativa:
/* Global: aplica a todos los destinos de ancla del sitio. */
html { scroll-behavior: smooth; }
/* :target es el elemento al que apunta el hash actual. */
:target { scroll-margin-top: var(--header-height, 64px); }
/* Si el usuario prefiere menos movimiento, salto instantáneo. */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
Prefetch del destino más probable. Astro tiene prefetch nativo. Si la franja tiene un botón que sabes que es el más clicado (por analytics o por intención del negocio), márcalo con data-astro-prefetch y el navegador descargará la página destino mientras el visitante lee el hero. Cuando hace clic, la transición es instantánea. Esto se hace dentro del propio componente o, sin tocarlo, envolviendo el ‹SectionMenu› con un script que añada el atributo al ‹a› del primer botón. La opción menos intrusiva es la primera variante: aceptar un prop opcional prefetch?: boolean en MenuItem.
ariaLabel por instancia, no global. El componente acepta ariaLabel y por defecto es "Secciones del sitio". Si la página usa la franja dos veces —una bajo el hero, otra al cierre—, los dos ‹nav› competirían por el mismo nombre accesible. Cámbialos: el de arriba se queda con el default, el de abajo recibe ariaLabel="Sigue explorando". Los lectores de pantalla diferencian, y los enlaces internos navegables por teclado quedan separados como dos landmarks distintos.
Edge cases y debugging
Cinco situaciones que aprendimos al implementar la franja en sitios de clientes distintos:
Safari iOS y scroll-behavior: smooth. Safari iOS no soporta scroll-behavior: smooth con la fluidez de Chrome; el salto es más brusco y a veces “se traba” en la mitad del recorrido. No es bug nuestro, es Safari. La defensa es no depender de la suavidad: la franja debe funcionar sin él. Probarla con scroll-behavior: auto y verificar que el destino aparece debajo del header (el scroll-margin-top cubre esto).
Barra URL retráctil de Android Chrome y :target. Cuando Android Chrome retrae la barra URL al hacer scroll, el viewport crece 56 px de golpe. Si llegas a un :target justo cuando la barra se retrae, el destino salta hacia arriba 56 px y se queda mal posicionado. Patrón seguro: usar scroll-margin-top: clamp(64px, 12vh, 96px) para dar holgura sobre el header sticky.
View transitions con anclas internas. Si Astro 6 hace transición entre /landing con #faq activo y /landing#contacto, la transición conserva el scroll position en lugar de saltar al nuevo ancla. El bug fue reportado en Astro 5 y persiste en 6.x cuando transition:animate="none" no está declarado. Solución: agregar transition:animate="none" al ‹SectionMenu /› o al contenedor padre cuando la página tenga anclas internas activas.
Anclas dentro de ‹details› colapsado. Si tu FAQ usa ‹details› y la URL llega con #faq-3, el navegador no expande automáticamente el ‹details› cuyo contenido contiene #faq-3. Resultado: el :target apunta a un elemento oculto y el visitante ve la sección colapsada. Defensa: pequeño script en ‹head› que detecta location.hash y abre el ‹details› padre con closest('details')?.setAttribute('open', ''). Pesa 200 bytes; vale el caso.
scroll-margin-top y header con altura dinámica. Si tu header colapsa al hacer scroll (sticky pequeño), scroll-margin-top: 64px es correcto en estado colapsado pero muy chico en estado normal. Patrón: usar scroll-margin-top: var(--header-height-current, 64px) y actualizar la variable CSS con un ResizeObserver que observa el header. Coste: 30 líneas JS; beneficio: ancla siempre debajo del header real.
prefers-reduced-motion y scroll-behavior: smooth. La regla @media (prefers-reduced-motion: reduce) ❴ html ❴ scroll-behavior: auto; ❵ ❵ es obligatoria por WCAG 2.2 SC 2.3.3 (Animation from Interactions). Sin ella, usuarios con vestíbulo sensible o con vértigo experimentan mareo cuando hacen clic en una ancla. Lo aprendimos cuando un usuario con esclerosis múltiple reportó que «el sitio le daba náuseas». La regla es un one-liner; el costo de no ponerla es excluir a ~5% de la audiencia con condiciones vestibulares.
Anclas con caracteres especiales en el id. Si el destino tiene un id con acentos o espacios (id="qué-es"), el href correspondiente debe URL-encode (href="#qu%C3%A9-es"). Chrome y Firefox lo manejan; Safari iOS hasta 17.3 fallaba silenciosamente. Receta operativa: ids siempre en kebab-case ASCII (id="que-es"); el label visible puede tener tildes (‹h2›¿Qué es?‹/h2›).
Performance y accesibilidad
Lighthouse 12.0.2 sobre landing con franja + 4 anclas internas, Pixel 5, Slow 4G:
| Métrica | Solo hero | Hero + SectionMenu |
|---|---|---|
| LCP | 2.1 s | 2.2 s |
| CLS | 0.01 | 0.02 |
| INP | 70 ms | 78 ms |
| Score | 96 | 95 |
| HTML extra | base | +1.4 KB |
| CSS extra (gzipped) | base | +0.9 KB |
| JS extra | 0 | 0 |
Impacto en performance: prácticamente nulo. El componente es HTML + CSS puro, sin JavaScript.
WCAG 2.2 cumplidos:
- SC 1.3.1 (Info and Relationships): el
‹nav›conaria-labely los‹a›con‹span›paralabelysubmantienen estructura semántica. - SC 1.4.3 (Contrast): el texto del botón hereda
--c-fgsobre--c-bg-mute, ratio 11:1. El CTA verde + texto blanco: 3.2:1 (pasa para texto bold ≥14pt). - SC 2.1.1 (Keyboard): cada
‹a›es enfocable con Tab; el orden de tabulación sigue el DOM (items, luego CTA). - SC 2.4.4 (Link Purpose):
label+subdan contexto suficiente sin depender del contexto circundante. - SC 2.4.7 (Focus Visible): el
:focus-visibleglobal da outline 2px de marca con offset 2px. - SC 2.5.5 (Target Size): cada botón mide ≥44×44 CSS px en móvil (verificado en DevTools).
Casos donde NO usar este patrón
Páginas cortas (1-2 viewports). Si toda la página cabe en dos pantallazos, la franja es ruido —no hay nada que mapear—. Reserva el SectionMenu para páginas que requieren scroll significativo: landings de venta, fichas L4 con FAQ + relacionados, blog posts largos con índice.
Apps con navegación lateral persistente. Cuando el sitio es una webapp con sidebar (admin panel, dashboard), agregar una franja arriba duplica el sistema de navegación. La sidebar ya cumple el rol de “mapa de la sección”; la franja confunde.
Sitios sin scroll significativo en desktop. En catálogos chicos o landings de un solo viewport, el menú de sección queda visualmente “huérfano” —botones grandes sin contexto de lo que apuntan—. Antes de implementarlo, verifica que la página tenga al menos 3 secciones distintas con scroll real (no solo padding generoso).
Páginas donde el visitante debe seguir un orden lineal. Tutorial paso a paso, checkout multi-step, wizard de onboarding. Si saltar entre secciones rompe el flujo (paso 4 antes que el 2 no tiene sentido), la franja invita al error. Usa breadcrumbs lineales o steppers, no anchor bars.
SPAs con routing complejo. Si la página es un SPA con vistas que cambian sin recargar, las anclas a #id pueden conflictuar con el router (React Router, TanStack Router). La franja con href="#faq" puede disparar navegación del router en lugar de scroll. Solución: rutas planas, no SPAs, o un handler onClick que llame preventDefault() y haga scrollIntoView() manual sobre el elemento destino.
Checklist de implementación
- El
ctase arma conwaUrl(WA_MESSAGES.cotizar), nunca con unwa.metecleado. - Los
hrefinternos van sin barra final (/productos, no/productos/). - La franja tiene entre 4 y 6 botones; si supera 6, se rediseña la jerarquía del menú.
- Cada
MenuItemtienelabelcorto (1–2 palabras) ysubde 2–4 palabras. - Si la página usa el componente dos veces, cada
‹nav›recibe unariaLabeldistinto. - Para landings, hay un
:target ❴ scroll-margin-top ❵global que compensa el header sticky. - El último botón siempre es el CTA, en color de marca, con
external: truesi es WhatsApp.
Preguntas frecuentes
¿Puedo poner dos CTAs al final de la franja?
No con el componente actual. Acepta un solo cta opcional. Si necesitas dos acciones (por ejemplo, «Cotizar» y «Ver demo»), o las pones como dos items más en items (perdiendo el destacado) o extiendes el componente para aceptar ctaSecundario o un array. La primera opción es la pragmática; la segunda, la limpia.
¿Funcionan las anclas si tengo header sticky?
Sí, pero el salto deja el destino tapado por el header. La solución es scroll-margin-top en el elemento destino (o en :target de forma global), igualando la altura del header. Con eso, el ancla salta y deja al destino visible debajo del header. No necesitas JavaScript.
¿Por qué external: true solo aplica al CTA?
Porque los items son rutas internas del sitio y abrir pestañas nuevas para navegación interna rompe la expectativa del visitante (botón Atrás deja de funcionar, pierde contexto). El CTA es la excepción legítima: WhatsApp Web abre en otra app o pestaña, y el target="_blank" lo respeta sin perder la página de origen.
¿Sirve este menú como reemplazo del header en móvil?
No. La franja es un módulo de navegación complementario, no un sustituto del header. El header es el mapa global (siempre presente); la franja es un atajo a los destinos clave desde la página actual. Reemplazar uno por el otro confunde al visitante: el header se espera arriba, la franja se espera bajo el hero.
¿Cuál es la diferencia con un breadcrumb o un footer?
El breadcrumb dice dónde estás; la franja dice a dónde puedes ir (con un atajo a la conversión). El footer es el cierre informativo del sitio (legal, redes, columnas de enlaces); la franja de cierre es el último empujón a una acción concreta antes de que el visitante llegue al footer. Son tres piezas con trabajos distintos, y conviven sin solaparse.
¿Cómo se compara con el sticky bottom bar de iOS Safari o el Stripe pricing nav?
iOS Safari muestra a veces una barra inferior con acciones contextuales (compartir, marcador). El sticky bottom bar imita ese patrón en sitios móviles: CTA fijo al pie de pantalla. Funciona bien para landings de venta donde la decisión “comprar” se forma en el camino. La diferencia con SectionMenu es de posición: la franja es repartidora arriba; el sticky bar es CTA único abajo. No compiten; se complementan en landings agresivas. El Stripe pricing nav es un tab bar superior que cambia de plan visto, no de página; es exactamente el patrón de la franja pero como tabs interactivas con JavaScript. Para sitios estáticos, anchors planos; para apps interactivas, tabs.
¿Puedo poner el SectionMenu sticky bajo el header?
Sí, agregando position: sticky; top: var(--header-height, 64px); z-index: 30; al ‹nav›. Funciona en todos los navegadores modernos. El trade-off: pierde 56-72 px de viewport durante toda la sesión y compite con el scroll del contenido. Vale la pena solo en landings de venta largas (5+ secciones) donde el visitante consulta secciones frecuentemente; para artículos típicos, la franja arriba sin sticky es mejor.
¿La franja afecta SEO o el sitemap?
Como elemento de navegación, sí: Google la rastrea como links internos y los considera para PageRank interno. Una franja con href="/productos" en home pasa autoridad a /productos igual que un link en el body. Las anclas (#faq) no agregan URLs nuevas al sitemap (son fragmentos), pero sí ayudan al rastreo de “section snippets” en SERP cuando Google decide mostrar links a secciones específicas debajo del resultado principal.
Si necesitas convertir el cierre en una acción real (no solo un repartidor de tráfico), el siguiente paso es la estrategia: jerarquía del CTA, copywriting de items y cómo medir si la franja convierte o solo decora. Eso lo cubre Del menú de sección a la conversión.
Sigue leyendo
- Módulo Menú de sección en vivo
- Del menú de sección a la conversión
- Construir un sitio profesional con Astro y Markdown: 12 módulos
- WCAG 2.2 SC 2.4.4 — Link Purpose (In Context) (W3C)
- MDN —
scroll-margin-top(referencia oficial) - NN/g — Anchor Links (NNG sobre anchors internos y su impacto en UX)