Guía · Topbar

Barra utilitaria superior (la franja por encima del menú). Cada punto se alimenta de src/config/site.ts (fuente única); no se escribe a mano. Esto es lo que va en cada lugar:

  1. 1

    Propuesta principal

    Lo primero que se lee: una frase corta que posiciona la marca. El logotipo NO va aquí — va en el Header, justo debajo.

    Dato SITE.tagline

  2. 2

    Horario

    Señal de disponibilidad y confianza. Se oculta en móvil para priorizar el teléfono y WhatsApp.

    Dato CONTACT.schedule.display

  3. 3

    Teléfono

    Contacto directo con clic-para-llamar. El enlace tel: lo construye telUrl(), no se escribe a mano.

    Dato CONTACT.phone · telUrl()

  4. 4

    WhatsApp

    CTA principal de contacto. El enlace SIEMPRE se arma con waUrl(); el mensaje precargado sale de WA_MESSAGES.

    Dato waUrl(WA_MESSAGES.cotizar)

Edita en src/components/TopBar.astro · src/config/site.ts

Guía · Header

Barra de navegación principal (logotipo + menú), bajo el topbar. Todo el menú —escritorio, paneles y móvil— se genera desde NAV en src/config/site.ts (fuente única); no se escribe a mano. Esto es lo que va en cada lugar:

  1. 1

    Logotipo

    La marca, a la izquierda, enlazando a la home. Es el ancla de identidad y el «volver al inicio» que todos esperan. Aquí SÍ va el logo (en el topbar no).

    Dato SITE.brand · SITE.name

  2. 2

    Navegación

    Las secciones del sitio. No se hardcodea ningún enlace: se itera NAV, la misma fuente para escritorio y móvil. En móvil colapsa en el menú ☰.

    Dato NAV

  3. 3

    Paneles (mega / dropdown)

    Las secciones con hijos despliegan un panel al pasar el cursor o con el teclado; su contenido sale de la taxonomía, no de una lista aparte.

    Dato NAV[].panel · items

  4. 4

    CTA · Cotizar

    El botón de conversión a WhatsApp, siempre visible a la derecha. El enlace se arma con waUrl(); el mensaje precargado sale de WA_MESSAGES.

    Dato waUrl(WA_MESSAGES.cotizacion)

Edita en src/components/Header.astro · src/config/site.ts

Guía · Migas de pan

La ruta que muestra dónde está el visitante dentro de la jerarquía del sitio, justo debajo del header. Sirve para dos cosas a la vez: orientar y dejar volver a cualquier nivel superior, y alimentar el BreadcrumbList de schema.org que el buscador usa para mostrar la ruta en sus resultados. Cada página define su ruta una sola vez con la prop breadcrumbs; el JSON-LD lo emite buildSchema (no este componente, para no duplicarlo). Esto es cada eslabón:

  1. 1

    Raíz (Inicio)

    El primer eslabón: siempre enlaza a la home. Es el punto de partida de la ruta y el «volver al inicio» que todos esperan de la jerarquía.

    Dato items[0] · href '/'

  2. 2

    Eslabón intermedio

    Cada nivel ancestro entre la home y la página actual (categoría, subcategoría). Son enlaces: dejan saltar a cualquier nivel superior.

    Dato items[].href

  3. 3

    Separador

    El icono (›) entre eslabones. Es decorativo —va con aria-hidden— y solo marca la dirección de la jerarquía; nunca es un enlace.

    Dato SVG · aria-hidden

  4. 4

    Página actual

    El último eslabón: la página donde estás. No enlaza (ya estás ahí) y se marca con aria-current="page" para los lectores de pantalla.

    Dato item sin href · aria-current

Edita en src/components/Breadcrumbs.astro · prop breadcrumbs de cada página

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

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.

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.

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.

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.

Sigue leyendo

¿Listo para dar el siguiente paso?

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

¿Necesitas ayuda?