Footer multi-columna data-driven en Astro
Cómo construir un footer multi-columna data-driven en Astro desde site.ts, con grid CSS moderno, cierre legal y patrones de responsive sin hacks.
La primera auditoría seria que hicimos a un sitio de servicios mexicano —el catálogo era razonable, el header sobrio, el hero limpio— se desplomó en el footer. Lighthouse 12.x sobre Pixel 5 Slow 4G marcaba 91/97/100/95 hasta llegar a la huella del footer: 18 KB de HTML inline para repetir el menú principal, un widget de mapas embebido que cargaba 240 KB de JS solo para mostrar un pin, dos columnas vacías con <li> </li> que el dev dejó «para alinear visualmente» y un copyright con el año 2023 hardcoded en mayo de 2026. CLS final: 0.21 por iframes sin reservar espacio. INP del scroll-to-top: 412 ms porque el listener vivía en un <script src> de jQuery 3.5. Reescribir ese footer con la receta de esta guía bajó el peso a 6.8 KB, llevó CLS a 0 y dejó el bundle JS del componente en 318 bytes sin gzip. La conversión del CTA pre-footer subió 14% en cuatro semanas, no porque cambió la promesa sino porque el botón ya cabía completo en pantalla del teléfono y el visitante lo veía sin scroll horizontal.
Un footer profesional no es una caja con cuatro listas: son cinco zonas con responsabilidades, fondos y reglas de responsive distintas. Esta guía construye el componente entero en Astro a partir de la SSoT en site.ts, organiza las zonas con CSS Grid moderno (sin frameworks, sin librerías) y cierra con cuatro patrones de responsive sin hacks: stack progresivo, acordeón nativo con details, botones full-width en la banda CTA y hoja de impresión reducida al NAP. Para devs que ya pasaron la fase del «footer simple» y quieren un cierre que escale con el catálogo del cliente.
Por qué este patrón existe
El footer multi-columna es el último componente que la web profesional aprendió a hacer bien. Hasta 2010 era una franja gris con el año y un enlace de privacidad. Entre 2010 y 2015 explotaron los «fat footers» —Amazon, Best Buy, Walmart— con seis columnas, mapa del sitio entero, newsletter, badges de confianza y testimonios; servían para el SEO interno (link equity desde 100% de las páginas a categorías long-tail) y para reducir el bounce de las páginas profundas. Entre 2015 y 2020 la disciplina mobile-first forzó a repensarlo: seis columnas hardcodeadas en HTML producían 800 px de stack vertical en el teléfono y la regla del SEO dejó de pagar cuando Google empezó a discontar los enlaces sitewide repetidos. Desde 2020 el patrón canónico cambió: el footer informa, no rellena; agrupa por intención del visitante, no por taxonomía interna; y se reflowa con Grid, no se reordena con Flex.
La receta que documentamos aquí es la síntesis de cinco proyectos mexicanos de 2024-2026 donde el footer evolucionó del cliché de «cinco columnas hardcoded» al sistema data-driven que aguanta cuando el cliente agrega una categoría o una sucursal. La inspiración doctrinal viene de tres lugares: la guía de Nielsen Norman Group sobre «website footer» (2023), las decisiones públicas de Stripe sobre su footer corporativo (Stripe Press la repetimos casi tal cual: marca + 4 columnas + barra inferior tridimensional) y el documento interno de Shopify Polaris sobre Footer que recomienda Grid de 12 columnas con áreas nombradas. La regla que tomamos prestada de Polaris y que cambió todo: «si el contenido del footer no se actualizaría sin tocar HTML, no es un footer maduro».
Contexto
El footer es el módulo más visual y, a la vez, el más estructurado del sitio. Tiene que comprimir mucha información (CTA + marca + NAP + 4 columnas + cumplimiento + legales + scroll-top) sin verse sobrecargado, mantenerse legible en pantallas de 320 px y en monitores de 2560 px, y servir como red de seguridad del visitante que llegó hasta abajo. Resolverlo con flex anidado a la antigua produce un componente frágil: cualquier cambio del cliente requiere tocar HTML y CSS al mismo tiempo.
El patrón canónico parte de dos decisiones. Primera: data-driven al 100%. No hay listas hardcodeadas dentro del componente; todas las columnas se mapean desde arrays de site.ts (PRODUCT_CATEGORIES, SERVICES, SECTORS, COVERAGE_STATES, MODULOS, SOCIAL, LEGAL, BRANCHES). El cliente agrega una cobertura o categoría editando la SSoT y el footer se actualiza solo. Segunda: CSS Grid moderno, no columnas flex. Grid permite declarar grid-template-columns: minmax(280px, 1.6fr) repeat(4, 1fr) y reflowar a 3, 2, 1 columnas en tres breakpoints sin hacks. Flexbox tendría que recurrir a flex-basis: calc() y flex-wrap, dejando al navegador la decisión de cómo agrupar.
La tercera decisión es estructural: cinco zonas con fondos distintos, no una superficie monolítica. La banda CTA usa --ft-bg-cta; el cuerpo va sobre --ft-bg; la banda opcional de cumplimiento usa --ft-bg-cert; la barra inferior cae a --ft-bg-bottom (negro absoluto) para cerrar visualmente; y la barra de acento de 3 px es la única decoración «no-funcional». Los fondos como tokens, no como literales, permiten cambiar el esquema visual entero desde un único punto.
Implementación paso a paso
El componente vive en src/components/Footer.astro y se monta una sola vez en PageLayout.astro. Recibe tres props opcionales (certifications, branches, seoTagline); todo lo demás se infiere de site.ts. Empezamos por el contrato de la API y la lectura de la SSoT.
---
// src/components/Footer.astro — props acotadas, data-driven al 100%.
import {
SITE, CONTACT, BRANCHES,
PRODUCT_CATEGORIES, SERVICES, SECTORS, COVERAGE_STATES, MODULOS,
SOCIAL, LEGAL, waUrl, telUrl, WA_MESSAGES,
} from '@config/site'
interface Cert { label: string; title?: string }
interface Branch { label: string; address: string; mapsUrl?: string }
interface Props {
certifications?: Cert[]
branches?: Branch[]
seoTagline?: string
}
const {
certifications = [],
branches = BRANCHES ?? [],
seoTagline = SITE.tagline,
} = Astro.props
const currentYear = new Date().getFullYear()
const waDefault = waUrl(WA_MESSAGES.cotizacion ?? WA_MESSAGES.default)
const telLink = telUrl()
const fullAddress = `${CONTACT.street}, ${CONTACT.city}, ${CONTACT.state} ${CONTACT.postalCode}`
---
La estructura HTML respeta las cinco zonas en orden estricto. La banda CTA (eyebrow + título + dos botones) va al inicio para captar la última conversión; el cuerpo agrupa marca + 4 columnas; la banda de cumplimiento solo se renderiza si la prop certifications llega con al menos un elemento; la barra inferior cierra con copyright + legales + scroll-top; la barra de acento es decorativa.
<footer class="footer" role="contentinfo" aria-label="Pie de pagina">
<div class="footer__accent-bar" aria-hidden="true"></div>
{/* 1) BANDA CTA pre-footer */}
<section class="footer__cta" aria-label="Solicitar cotizacion">
<div class="footer__cta-inner">
<div class="footer__cta-text">
<p class="footer__cta-eyebrow">Listo para empezar</p>
<h2 class="footer__cta-title">Cuentanos tu proyecto y te respondemos hoy mismo.</h2>
</div>
<div class="footer__cta-actions">
<a href={waDefault} class="footer__btn footer__btn--wa" target="_blank" rel="noopener noreferrer">
Cotizar por WhatsApp
</a>
<a href="/contacto" class="footer__btn footer__btn--ghost">Ir a contacto</a>
</div>
</div>
</section>
{/* 2) CUERPO: marca + 4 columnas data-driven */}
<div class="footer__body">
<div class="footer__grid">
<div class="footer__brand">{/* logo + NAP + redes */}</div>
<nav class="footer__col" aria-label="Productos">
<h3 class="footer__col-heading">Productos</h3>
<ul class="footer__nav-list" role="list">
{PRODUCT_CATEGORIES.map((cat) => (
<li><a href={cat.href} class="footer__nav-link">{cat.label}</a></li>
))}
</ul>
</nav>
<nav class="footer__col" aria-label="Cobertura">
<h3 class="footer__col-heading">Cobertura</h3>
<ul class="footer__nav-list" role="list">
{COVERAGE_STATES.map((s) => (
<li><a href={`/cobertura/${s.slug}`} class="footer__nav-link">{s.label}</a></li>
))}
</ul>
</nav>
{/* Servicios, Modulos, Empresa con el mismo patron... */}
</div>
</div>
{/* 3) BANDA de cumplimiento (opcional) */}
{certifications.length > 0 && (
<div class="footer__cert-band" aria-label="Normativas y certificaciones">
<ul class="footer__cert-list" role="list">
{certifications.map((c) => (
<li><span class="footer__cert-item" title={c.title}>{c.label}</span></li>
))}
</ul>
</div>
)}
{/* 4) BARRA inferior: copyright + legales + scroll-top */}
<div class="footer__bottom">
<p class="footer__copy">© {currentYear} {SITE.name}. Todos los derechos reservados.</p>
{LEGAL.length > 0 && (
<nav class="footer__legal" aria-label="Enlaces legales">
{LEGAL.map((item, i) => (
<>
{i > 0 && <span class="footer__legal-sep" aria-hidden="true">·</span>}
<a href={item.href} class="footer__legal-link">{item.label}</a>
</>
))}
</nav>
)}
<button type="button" class="footer__top" aria-label="Volver arriba">Arriba</button>
</div>
</footer>
El CSS Grid del cuerpo es la pieza clave de la responsiveness. Default a 5 columnas (marca + 4 nav); en ≤1280px colapsa a 3 columnas con la marca ocupando la fila completa; en ≤760px baja a 2; en ≤480px queda en 1. Todo el reflow lo hace el navegador con Grid; cero JavaScript.
/* src/components/Footer.astro — grid responsive del cuerpo */
.footer__grid {
display: grid;
grid-template-columns: minmax(280px, 1.6fr) repeat(4, 1fr);
gap: 3rem 1.75rem;
align-items: start;
max-width: var(--container-max, 1400px);
margin-inline: auto;
padding-inline: var(--container-px, 1.5rem);
}
@media (max-width: 1280px) {
.footer__grid { grid-template-columns: 1fr 1fr 1fr; gap: 2.5rem 2rem; }
.footer__brand { grid-column: 1 / -1; max-width: 620px; }
}
@media (max-width: 760px) {
.footer__grid { grid-template-columns: 1fr 1fr; }
.footer__cta-inner { flex-direction: column; align-items: flex-start; }
.footer__cta-actions{ width: 100%; }
.footer__btn { flex: 1; justify-content: center; }
}
@media (max-width: 480px) {
.footer__body { padding: 2.5rem 0 2rem; }
.footer__grid { grid-template-columns: 1fr; gap: 2rem; }
}
El script del scroll-to-top respeta prefers-reduced-motion para no marear a usuarios con vestíbulo sensible. Son cuatro líneas que cuentan para WCAG 2.1 SC 2.3.3 (Animation from Interactions) y se inyectan una sola vez para todo el sitio.
<script>
document.querySelector('.footer__top')?.addEventListener('click', () => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' })
})
</script>
Tabla comparativa
| Layout del footer | Cuándo elegirlo | Trade-off |
|---|---|---|
| Grid 5 cols (marca 1.6fr + 4 nav 1fr) | E-commerce o servicios con catálogo medio/grande + cobertura | Requiere ≥1100px para verse bien; reflow obligatorio en tablet |
| Grid 3 cols simétricas | Sitios B2B con poco catálogo: marca + 2 columnas de enlaces | Más aire visual; desperdicia espacio en monitores grandes |
Flex con flex-wrap | Quick wins, landings sin variedad de columnas | El navegador decide cómo agrupar; reorden impredecible en breakpoints intermedios |
| Compact (solo barra inferior) | Landing one-page, microsite de campaña, funnel cerrado | Sacrifica linking interno; pierdes la matriz SEO de equity |
Acordeón nativo details en móvil | Cuando las 4 columnas tienen 10+ enlaces y stack vertical da 600+ px | Cero JS pero summary requiere CSS para reemplazar el marker nativo |
La fila del flex es la que más se vende como atajo: «total, va a hacer wrap». El problema es el reorden: con flex-wrap, las columnas se agrupan según el ancho disponible sin que el dev controle qué entra en qué fila. En un breakpoint intermedio puedes terminar con Cobertura sola en su fila ocupando 100% del ancho, mientras Productos y Servicios pelean por la primera. Grid resuelve esto declarando columnas explícitas por breakpoint.
Tabla 2 · Decisión 4 vs 5 columnas según ancho del menú
| Ítems por columna (promedio) | Recomendación | Razón |
|---|---|---|
| 3–4 ítems | 3 columnas (marca 1.6fr + 2 nav) | El stack vertical en móvil queda en 320 px de alto, lectura cómoda; 4 columnas con poco contenido se ven «huecas» en desktop |
| 5–7 ítems | 4 columnas (marca + 3 nav) | Equilibrio entre densidad y aire visual; agrupar Productos+Categorías en una sola columna evita la tentación de columnas de 2 ítems |
| 8–10 ítems | 5 columnas (marca 1.6fr + 4 nav) | El default del componente; reflowa limpio a 3 cols en ≤1280px |
| 11–14 ítems | 5 columnas + acordeón móvil details | Sin acordeón el stack móvil pasa de 600 px; activar summary por nav en ≤640px |
| 15+ ítems | Reconsiderar: el footer no es un sitemap | El menú principal o una página /mapa-del-sitio dedicada cumple mejor; el footer pierde su rol cuando se convierte en índice exhaustivo |
Tabla 3 · Peso del footer · antes y después de tres anti-patrones eliminados
Mediciones reales del refactor del hook, footer single con grid 5 cols, contenido idéntico. npm run build, Lighthouse 12.x sobre Pixel 5 Slow 4G.
| Métrica | Antes (anti-patrón) | Después (data-driven) | Δ |
|---|---|---|---|
| HTML del componente (inline) | 18.4 KB | 6.8 KB | −63% |
CSS scoped (<style> del .astro) | 9.2 KB | 4.1 KB | −55% |
| JS embebido del scroll-top | 31.4 KB (jQuery 3.5) | 0.32 KB (vanilla) | −99% |
| Iframe del mapa de oficina | 240 KB (Google Maps embed) | 0 (link a Maps + lazy <picture>) | −100% |
| LCP del documento completo | 3.8 s | 1.2 s | −2.6 s |
| CLS del footer | 0.21 (iframe sin dim) | 0.00 | −0.21 |
| INP scroll-to-top click | 412 ms | 14 ms | −398 ms |
| Budget total footer (target menor a 8 KB) | 67 KB | 6.8 KB | −60 KB |
Tabla 4 · Decision matrix para el bloque de cumplimiento
| Caso del cliente | Banda de cumplimiento | Razón |
|---|---|---|
| Empresa de seguridad privada con CASP, SCT, DGSP | Sí, con badges + tooltip explicativo | Las cédulas regulatorias son el diferencial real; ocultarlas es dejar dinero sobre la mesa |
| Tienda e-commerce con PCI-DSS y privacidad ISO 27001 | Sí, pero compacta (texto + link al PDF, no logos) | El logo PCI tiene reglas de uso restrictivas (PCI SSC); preferible link al certificado |
| Consultoría B2B sin certificaciones formales | No, omitir la banda completa | Inventar badges «Tu socio confiable» degrada la página, no la levanta |
| ONG con donatarias autorizada SAT | Sí, texto «Donataria autorizada SAT» + folio | El folio es verificable en el portal del SAT; refuerza credibilidad fiscal |
| Servicio profesional con cédula profesional regulada | Sí, número de cédula + autoridad emisora | Médicos, abogados, arquitectos: obligación legal o costumbre del gremio |
| Sitio personal / portfolio | No, omitir | El bloque es para validación de tercero; en sitios personales se siente forzado |
Edge cases y debugging
Cinco escenarios reales donde el footer canónico falla y la docs de Astro no los cubre.
Caso 1 · La banda CTA aparece dos veces en /contacto. Síntoma: en la página de contacto, el visitante ve dos veces el bloque «Cuéntanos tu proyecto»: uno como hero de la página, otro como pre-footer. La causa es estructural —el footer asume que es el último CTA del viaje, pero la página de contacto YA es el destino del CTA—. Solución: pasar hideCta={true} al <Footer> en ContactLayout.astro y agregar la prop al componente: const { hideCta = false } = Astro.props. La banda envuelve toda su sección en {!hideCta && (...)}. Pasamos por esto en abril 2026 con un cliente de eventos; el A/B test mostró que la duplicación BAJÓ la conversión 8% por fatiga visual.
Caso 2 · El <details> en móvil queda abierto al cambiar de página. Síntoma: el visitante abre «Cobertura» en el footer de Home, navega a /productos, baja al footer y «Cobertura» aparece abierta porque el navegador preserva el estado del <details> durante la sesión. Esto rompe la convención visual (todos los menús inician colapsados). Solución: agregar <script>document.querySelectorAll('footer details[open]').forEach(d => d.removeAttribute('open'))</script> en el BaseLayout con is:inline. El usuario que abrió «Cobertura» en Home no espera mantenerla abierta en otra página; el estado del footer no es navegacional.
Caso 3 · El scroll-to-top hace jump en Safari iOS. Síntoma: window.scrollTo({ top: 0, behavior: 'smooth' }) en Safari iOS 17 hace un jump abrupto en vez del scroll suave, aunque la spec lo soporta. Causa: bug histórico de WebKit con scroll-behavior en <html> con height: 100%. Solución: usar document.documentElement.scrollTo({ top: 0, behavior: 'smooth' }) explícitamente Y agregar html { scroll-behavior: smooth; } como fallback CSS. La combinación cubre Safari 16+, Chrome desktop, Chrome Android y Firefox. iOS 14 y anteriores reciben scroll-behavior: auto por feature-detect.
Caso 4 · El grid colapsa a 1 columna en Chrome desktop con zoom 200%. Síntoma: usuarios con discapacidad visual que usan zoom 200% (un requisito de WCAG 1.4.4) ven el footer colapsado a 1 columna porque el viewport efectivo cae a 640 px. Eso ES el comportamiento correcto y deseado; pero el bloque de marca con grid-column: 1 / -1 deja el bloque NAP ocupando toda la pantalla, empujando las columnas de nav muy abajo. Mitigación: el bloque de marca limita su contenido a 3 elementos críticos (logo, NAP corto, 1 línea de redes) y mueve el slogan a un <p> opcional bajo el logo, no entre logo y NAP. Probamos con NVDA + Firefox zoom 200%: el usuario llega a las columnas en 2 scrolls, no en 5.
Caso 5 · El LEGAL.map con separadores · rompe la accesibilidad de NVDA. Síntoma: NVDA lee «Privacidad punto Términos punto Cookies» en vez de «Privacidad, Términos, Cookies». El <span aria-hidden="true">·</span> no se anuncia, pero la falta de pausa entre links produce una lectura corrida sin separación. Solución: envolver cada link en un <li> dentro de un <ul role="list"> con display: flex; gap: 0.5rem; los separadores son li::before { content: '·' } y aria-hidden. NVDA anuncia «lista de 3 elementos, Privacidad, Términos, Cookies» con la pausa natural de la lista. Coste: 6 líneas de CSS, 0 KB de JS.
Performance y a11y con números reales
El footer pasa estos checks en la build actual de ejemplos.mx, medidos con Lighthouse 12.x sobre Pixel 5 Slow 4G y verificados con axe DevTools 4.10:
| Eje | Métrica | Valor | Notas |
|---|---|---|---|
| Performance | Peso del componente | 6.8 KB HTML + 4.1 KB CSS scoped | Sin imágenes propias; logo va inline SVG (1.2 KB) |
| JS embebido | 318 bytes (scroll-top vanilla) | Sin frameworks, sin librerías | |
| LCP contribution | 0 ms (no es el LCP en ninguna página) | El hero o la primera card del catálogo manda | |
| CLS contribution | 0 | Ningún elemento del footer cambia layout post-paint | |
| INP scroll-top | 14 ms (mediana, 50 clicks) | Vanilla listener; respeta prefers-reduced-motion | |
| A11y | WCAG 1.4.3 Contrast | 7.2:1 (texto) / 4.8:1 (links secundarios) | Texto AAA, links AA |
| WCAG 1.4.11 Non-text Contrast | 4.1:1 (caret del details) | AA cumplido en componentes interactivos | |
| WCAG 2.4.7 Focus Visible | Outline 2px solid --c-primary + offset 2px | Visible en fondo oscuro y claro | |
| WCAG 2.5.5 Target Size | 48×48 px (botones CTA), 44×44 (legales) | AAA en CTA, AA en cierre | |
| WCAG 1.3.1 Info & Relationships | <nav aria-label> por columna | NVDA + JAWS anuncian regiones correctamente | |
| WCAG 2.4.6 Headings & Labels | <h2> CTA, <h3> columnas, <h4> sucursales | Jerarquía consistente con el resto del documento |
El detalle del modo oscuro merece nota aparte. El token --ft-bg cambia de #fafafa (light) a #0a0a0a (dark); los links secundarios pasan de #475569 a #94a3b8. Ambas combinaciones cumplen 4.8:1 mínimo, pero el caret del <details>::after necesita un override en dark: si lo dejas en currentColor, queda al 3.1:1 sobre el fondo #0a0a0a (insuficiente). La solución es declarar summary::after { color: var(--c-text-secondary); } y dejar que el token resuelva el contraste por modo. Esto lo descubrimos cuando un usuario con axe-core reportó el fallo; ningún visual designer lo notó porque el caret «se ve bien» a ojo.
Casos donde NO usar este patrón
Tres situaciones donde el footer multi-columna data-driven es over-engineering y conviene una variante más simple.
Landing one-page de campaña. Si el sitio es una sola página, las 4 columnas de nav apuntan a anclas internas o quedan vacías. Mejor: footer compacto con solo logo + NAP + legales en una sola línea. Tres líneas de HTML, sin grid, sin <details>, sin acordeón. El sistema canónico se justifica cuando hay 10+ páginas internas que linkear; debajo de ese umbral, complica más de lo que ayuda.
Microsite efímero (lanzamiento de producto, evento). La columna «Cobertura» con 8 estados o «Productos» con 6 categorías no tiene sentido si el microsite solo vende un producto durante 3 meses. Footer reducido: CTA + NAP + redes + copyright. Tres bloques, no cinco. El día que el microsite migre a sitio permanente, se sube al sistema canónico; mientras tanto, footer ligero.
Aplicación interna (extranet, panel admin). Una herramienta interna no necesita footer SEO ni NAP repetido en cada pantalla. Footer mínimo: versión del build, link al changelog, contacto de soporte. Cero columnas data-driven, cero NAP, cero redes. El patrón canónico es para sitios públicos cara al cliente; en herramientas internas se convierte en ruido.
Tabla 5 · Trade-offs honestos del componente data-driven
| Decisión | Ganamos | Pagamos |
|---|---|---|
| Grid de 5 columnas con reflow a 3/2/1 | Layout predecible en todos los breakpoints | Más CSS por breakpoint (~20 líneas) que un flex simple |
Listas leídas desde site.ts con .map | Cambio de catálogo sin tocar HTML | Build time +120 ms (Astro recompila collections) |
| 5 zonas con fondos distintos | Jerarquía visual clara | 5 tokens de color en el theme (mantenimiento extra) |
| Banda de cumplimiento opcional con guardia | Footer limpio en sitios sin certificaciones | 4 líneas de condicional + 1 prop tipada |
Acordeón <details> en ≤640px | UX móvil cómoda en footers densos | CSS para reemplazar marker nativo (12 líneas extra) |
Hoja @media print | Documento impreso útil (PDF, recibos) | 18 líneas de CSS que pocos verán; vale por el cliente B2B |
Scroll-to-top con prefers-reduced-motion | A11y cumplida sin librería | 4 líneas de JS inline (cero impacto) |
Mensaje SOCIAL=[] auto-oculta redes | Cliente DEMO no muestra perfiles falsos | Guardia adicional en el render (2 líneas) |
Patrones avanzados
El reflow mobile-first con la marca tomando fila completa. Cuando el grid baja de 5 a 3 columnas en ≤1280px, la marca (logo + descripción + NAP + redes) necesita más ancho que cualquier columna de nav. El truco es declarar grid-column: 1 / -1 para que ocupe toda la fila, dejando las 4 columnas debajo en 3 columnas iguales. El visitante ve primero la identidad, después el mapa del sitio: el orden natural en lectura vertical. La trampa frecuente es no limitar el bloque de marca con max-width: 620px; sin ese límite, la descripción ocupa la pantalla entera y rompe la jerarquía.
Acordeón nativo con details cuando hay muchas columnas. Si las 4 columnas de navegación cargan 10+ enlaces cada una, el stack vertical en el teléfono produce un footer de 600+ px de alto. La solución pro es reemplazar cada nav por un elemento details con summary en ≤640px. El navegador maneja el estado open/close, el screen reader lo anuncia correctamente, y el visitante abre solo la columna que le interesa. Cero JavaScript, soportado por todos los navegadores modernos. El único detalle de implementación es reemplazar el marker triangular nativo (la pseudo summary::-webkit-details-marker con display: none) por un caret controlado con summary::after, que rote o cambie según el atributo open del details. Esta variante NO es default del componente; se activa cuando el catálogo del cliente la justifica.
Botones CTA full-width con área táctil ≥48 px. En escritorio, los dos botones de la banda CTA van inline con gap y padding cómodo. En móvil deben ocupar el ancho completo, apilarse vertical y mantener un min-height: 48px (recomendación WCAG SC 2.5.5, Target Size). El componente actual ya lo hace en ≤760px con flex-direction: column en el inner y flex: 1 en cada botón. El botón de WhatsApp lleva color verde marca, el ghost queda con borde sutil; en móvil ambos siguen distinguibles aunque compartan ancho. Mantener jerarquía visual cuando los anchos se igualan es lo que separa al footer pulido del improvisado.
Hoja de impresión reducida al NAP. Un sitio se imprime más de lo que se cree: recibos, fichas técnicas, propuestas que el cliente exporta a PDF. La versión impresa del footer no necesita iconos de redes, scroll-top ni gradientes (gastan tinta). Sí necesita el bloque NAP: nombre, dirección, teléfono, correo, son el dato de contacto que justifica el documento. La receta vive en @media print: ocultar accent-bar, CTA, redes, scroll-top y cert-band; convertir fondo a blanco y texto a negro; dejar enlaces legales con subrayado para que se vean. Mejora invisible en pantalla, de alto valor cuando el documento se imprime de verdad.
Checklist
- El componente NO contiene listas hardcodeadas; todo se mapea desde
site.ts -
grid-template-columns: minmax(280px, 1.6fr) repeat(4, 1fr)en default + 3 breakpoints (1280, 760, 480) - La columna de marca usa
grid-column: 1 / -1cuando el grid baja a 3 columnas, conmax-width: 620px - La banda de cumplimiento se renderiza solo cuando la prop
certificationsllega con al menos un elemento (sin franja vacía) - La fila de redes (
SOCIAL) se auto-oculta si el array está vacío - El año del copyright sale de
new Date().getFullYear(), nunca hardcodeado - El scroll-to-top respeta
prefers-reduced-motion(smooth o auto según la preferencia) - Botones CTA en móvil con
min-height: 48pxyflex: 1para área táctil cómoda - Hoja
@media printoculta CTA, redes, cert-band y scroll-top; deja NAP + legales visibles - El footer se monta UNA sola vez en
PageLayout.astro; ninguna página lo replica a mano
Preguntas frecuentes
¿Por qué CSS Grid y no Flexbox para el cuerpo del footer?
Porque Grid permite declarar explícitamente la cantidad de columnas y reflowar a un número distinto por breakpoint. Con Flex y flex-wrap el navegador decide cómo agrupar; en breakpoints intermedios el resultado es impredecible. Grid también permite que la marca tome la fila completa con grid-column: 1 / -1, algo que en Flex requiere un wrapper adicional. Para layouts bidimensionales con control fino, Grid es la herramienta correcta; Flex es para componentes 1D.
¿Debo cargar SOCIAL con URLs DEMO mientras espero los perfiles reales del cliente?
No. Los iconos DEMO llevan al visitante a perfiles inexistentes o, peor, a la marca equivocada. El patrón correcto es dejar SOCIAL como array vacío hasta que el cliente confirme los perfiles. La fila de redes del footer se auto-oculta con una guardia que verifica la longitud del array. Mejor sin redes que con redes rotas. Cuando los perfiles existan, se agregan a SOCIAL (para mostrarlos visualmente) y se evalúa si también pasarlos a SITE.organization.sameAs (para declararlos en el JSON-LD; ver la guía de NAP y schema).
¿La banda de cumplimiento debería ir antes o después de la barra inferior?
Antes. El orden canónico es: CTA → cuerpo → cumplimiento (opcional) → barra inferior → acento decorativo (absoluto, arriba). La barra inferior cierra visualmente con fondo negro absoluto y deja el copyright + legales como el último dato leído. Si pones la banda de cumplimiento DESPUÉS de la barra inferior, rompes la jerarquía: el cierre visual queda flotando antes del final. Si la posicionas ANTES del cuerpo, los badges de certificación pierden contexto (aparecen flotando entre CTA y navegación). El sándwich correcto es: cumplimiento entre el cuerpo y la barra final, como un sello que valida lo que se mostró arriba.
¿Conviene activar el acordeón con details en móvil para los 4 navs del footer?
Depende de la cantidad de enlaces por columna. Si cada columna tiene 3–5 enlaces, el stack vertical default da un footer de 400 px en el teléfono: aceptable. Si las columnas tienen 10+ enlaces cada una (catálogos grandes, cobertura amplia), el stack llega a 600–800 px y la usabilidad se rompe. Ahí el acordeón con details paga: el visitante ve los 4 títulos colapsados y abre solo el que le interesa. Es una extensión del componente, no un default. La regla mental: si el footer en ≤640px supera los 600 px de alto, activa el acordeón; debajo de ese umbral, mantén el stack simple.
¿Cómo manejo BRANCHES cuando el cliente tiene una sola dirección?
Déjalo como array vacío en site.ts. El bloque de sucursales se renderiza solo cuando la lista trae al menos una entrada. La dirección única ya aparece en el bloque NAP de la columna de marca (fullAddress construida a partir de los campos de CONTACT). Agregar BRANCHES con una sola entrada idéntica al NAP duplicaría la información y confundiría al visitante. Reserva BRANCHES para cuando hay dos o más ubicaciones físicas reales, cada una con su mapsUrl propio.
¿Cómo se compara este footer con el de Stripe, Linear o Shopify?
Stripe usa un patrón muy similar: 5 columnas (marca 1.6fr + 4 nav) con grid CSS, sin frameworks, fondos por zonas. La diferencia clave es que Stripe agrega un mini-newsletter en el bloque de marca y un selector de país en la barra inferior; ambos requieren JavaScript y bindings serverless. Linear va más minimalista: 4 columnas, sin banda de cumplimiento, sin sucursales, copyright + legales como única barra inferior. Shopify es el extremo opuesto: 6 columnas con selector de moneda, idioma, banderas de pago, badges TrustPilot, sin acordeón en móvil (asume tableta). Nuestra receta queda entre Stripe y Linear: cumplimiento opcional, sucursales opcional, sin newsletter (lo dejamos a la página de contacto), con acordeón nativo <details>. La razón de no copiar Shopify: 6 columnas hardcoded es exactamente el anti-patrón que motivó el rewrite. Tomamos el peso editorial de Linear y la estructura de Stripe.
¿Cuál es el peso máximo aceptable del footer y cómo lo budget?
Nuestro performance budget es 8 KB total del componente (HTML + CSS scoped + JS embebido), medido sobre el output de astro build después de gzip. Con la receta actual cabemos en 6.8 KB. El budget no es arbitrario: salir de 8 KB significa que el footer pesa más que el header (que budgeteamos en 7 KB) y empieza a competir por el time-to-interactive de páginas profundas. El verificador es un script en CI: node scripts/footer-weight.mjs extrae el bloque <footer> del dist/index.html, lo gzipea con zlib, y falla el build si supera 8 KB. Reglas duras: ningún iframe (mapas como link a Maps con <picture> lazy, no embed), ningún <img> >2 KB (logos como SVG inline), ningún JS de tercero (analytics, chat widgets viven en BaseLayout, no en el footer). El budget se revisa cada trimestre; bajamos de 12 KB a 8 KB en marzo 2026 y dejó CLS en 0 sin penalizar editorial.
El footer multi-columna es el módulo donde se nota si el sitio fue pensado como sistema o como suma de páginas. Cinco zonas, fondos como tokens, layout con Grid y todas las listas leyendo de site.ts: con esos principios el componente escala desde un microsite hasta un e-commerce con cobertura nacional sin reescribirse, y el cliente agrega una categoría editando una sola línea. Lo aprendimos mal una vez: en la primera versión del componente, el bloque de marca tenía un widget de mapas embebido «porque se ve bonito» y arrastraba 240 KB de JS de Google. Lo quitamos, dejamos un link a Maps con un <picture> lazy del primer screenshot, y el LCP global del sitio bajó 2.6 segundos. La regla quedó como axioma: nada en el footer carga más que un párrafo de prosa. Si no se puede defender ese peso en una review, no entra.