guias

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.

Footer multi-columna data-driven en Astro

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>&nbsp;</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 footerCuándo elegirloTrade-off
Grid 5 cols (marca 1.6fr + 4 nav 1fr)E-commerce o servicios con catálogo medio/grande + coberturaRequiere ≥1100px para verse bien; reflow obligatorio en tablet
Grid 3 cols simétricasSitios B2B con poco catálogo: marca + 2 columnas de enlacesMás aire visual; desperdicia espacio en monitores grandes
Flex con flex-wrapQuick wins, landings sin variedad de columnasEl navegador decide cómo agrupar; reorden impredecible en breakpoints intermedios
Compact (solo barra inferior)Landing one-page, microsite de campaña, funnel cerradoSacrifica linking interno; pierdes la matriz SEO de equity
Acordeón nativo details en móvilCuando las 4 columnas tienen 10+ enlaces y stack vertical da 600+ pxCero 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ónRazón
3–4 ítems3 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 ítems4 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 ítems5 columnas (marca 1.6fr + 4 nav)El default del componente; reflowa limpio a 3 cols en ≤1280px
11–14 ítems5 columnas + acordeón móvil detailsSin acordeón el stack móvil pasa de 600 px; activar summary por nav en ≤640px
15+ ítemsReconsiderar: el footer no es un sitemapEl menú principal o una página /mapa-del-sitio dedicada cumple mejor; el footer pierde su rol cuando se convierte en índice exhaustivo

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étricaAntes (anti-patrón)Después (data-driven)Δ
HTML del componente (inline)18.4 KB6.8 KB−63%
CSS scoped (<style> del .astro)9.2 KB4.1 KB−55%
JS embebido del scroll-top31.4 KB (jQuery 3.5)0.32 KB (vanilla)−99%
Iframe del mapa de oficina240 KB (Google Maps embed)0 (link a Maps + lazy <picture>)−100%
LCP del documento completo3.8 s1.2 s−2.6 s
CLS del footer0.21 (iframe sin dim)0.00−0.21
INP scroll-to-top click412 ms14 ms−398 ms
Budget total footer (target menor a 8 KB)67 KB6.8 KB−60 KB

Tabla 4 · Decision matrix para el bloque de cumplimiento

Caso del clienteBanda de cumplimientoRazón
Empresa de seguridad privada con CASP, SCT, DGSPSí, con badges + tooltip explicativoLas cédulas regulatorias son el diferencial real; ocultarlas es dejar dinero sobre la mesa
Tienda e-commerce con PCI-DSS y privacidad ISO 27001Sí, 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 formalesNo, omitir la banda completaInventar badges «Tu socio confiable» degrada la página, no la levanta
ONG con donatarias autorizada SATSí, texto «Donataria autorizada SAT» + folioEl folio es verificable en el portal del SAT; refuerza credibilidad fiscal
Servicio profesional con cédula profesional reguladaSí, número de cédula + autoridad emisoraMédicos, abogados, arquitectos: obligación legal o costumbre del gremio
Sitio personal / portfolioNo, omitirEl 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:

EjeMétricaValorNotas
PerformancePeso del componente6.8 KB HTML + 4.1 KB CSS scopedSin imágenes propias; logo va inline SVG (1.2 KB)
JS embebido318 bytes (scroll-top vanilla)Sin frameworks, sin librerías
LCP contribution0 ms (no es el LCP en ninguna página)El hero o la primera card del catálogo manda
CLS contribution0Ningún elemento del footer cambia layout post-paint
INP scroll-top14 ms (mediana, 50 clicks)Vanilla listener; respeta prefers-reduced-motion
A11yWCAG 1.4.3 Contrast7.2:1 (texto) / 4.8:1 (links secundarios)Texto AAA, links AA
WCAG 1.4.11 Non-text Contrast4.1:1 (caret del details)AA cumplido en componentes interactivos
WCAG 2.4.7 Focus VisibleOutline 2px solid --c-primary + offset 2pxVisible en fondo oscuro y claro
WCAG 2.5.5 Target Size48×48 px (botones CTA), 44×44 (legales)AAA en CTA, AA en cierre
WCAG 1.3.1 Info & Relationships<nav aria-label> por columnaNVDA + JAWS anuncian regiones correctamente
WCAG 2.4.6 Headings & Labels<h2> CTA, <h3> columnas, <h4> sucursalesJerarquí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ónGanamosPagamos
Grid de 5 columnas con reflow a 3/2/1Layout predecible en todos los breakpointsMás CSS por breakpoint (~20 líneas) que un flex simple
Listas leídas desde site.ts con .mapCambio de catálogo sin tocar HTMLBuild time +120 ms (Astro recompila collections)
5 zonas con fondos distintosJerarquía visual clara5 tokens de color en el theme (mantenimiento extra)
Banda de cumplimiento opcional con guardiaFooter limpio en sitios sin certificaciones4 líneas de condicional + 1 prop tipada
Acordeón <details> en ≤640pxUX móvil cómoda en footers densosCSS para reemplazar marker nativo (12 líneas extra)
Hoja @media printDocumento impreso útil (PDF, recibos)18 líneas de CSS que pocos verán; vale por el cliente B2B
Scroll-to-top con prefers-reduced-motionA11y cumplida sin librería4 líneas de JS inline (cero impacto)
Mensaje SOCIAL=[] auto-oculta redesCliente DEMO no muestra perfiles falsosGuardia 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 / -1 cuando el grid baja a 3 columnas, con max-width: 620px
  • La banda de cumplimiento se renderiza solo cuando la prop certifications llega 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: 48px y flex: 1 para área táctil cómoda
  • Hoja @media print oculta 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

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.

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.

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.

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.

Sigue leyendo

¿Listo para dar el siguiente paso?

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

¿Necesitas ayuda?