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

Grid responsive de categorías en Astro

Del mobile-first al desktop con Astro y tokens.css: cómo armar un grid de CategoryCard que sobrevive a 380px, 768px y 1024px sin reflujos.

Grid responsive de categorías en Astro

Un grid de tarjetas en escritorio se ve bien casi sin esfuerzo: cuatro columnas, gap razonable, hover decoroso. El problema empieza al rotar el iPhone SE: la imagen brinca al cargar, los chips se desbordan, el CTA se sale del viewport. Este artículo arma un grid de CategoryCard que sobrevive a los cinco breakpoints reales del proyecto —1024, 768, 640, 480 y 380 px— sin caer en trampas comunes como el min-height inventado o el width: 100vw que ignora los safe-area iOS.

El enfoque es mobile-first puro y se apoya en dos pilares: el grid lo gobierna el padre, no la tarjeta; y todos los valores que parecen mágicos (gap, container, alturas) vienen de variables CSS declaradas en src/styles/tokens.css. Cambiar el espaciado de todo el sitio se hace en un archivo.

Contexto

CategoryCard.astro es deliberadamente agnóstico al grid. Su HTML raíz es un ‹article› con display: flex; flex-direction: column; height: 100% para que el CTA quede anclado al fondo —ese es el truco que mantiene todas las tarjetas de una fila alineadas al pie aunque el blurb difiera en longitud—. El resto es responsabilidad del contenedor padre, que decide cuántas columnas hay a cada breakpoint.

Hay cinco breakpoints en el proyecto, no tres. La trampa del «mobile, tablet, desktop» olvida los teléfonos pequeños (380 px, iPhone SE) y los pequeños/ medianos (480 px). El sitio se diseña empezando por 380 y crece desde ahí. La otra regla dura: el --container-max pasa de 90 % a 100 % en pantallas ≤768 px y el gutter se duplica con --container-px para evitar que las tarjetas se peguen al borde del cristal.

La prevención de CLS no es opcional. Cada ‹img› declara width="640" height="400" (ratio 16:10) para que el navegador reserve el hueco antes de descargar el archivo. Sin esos atributos, el grid colapsa al pintar la imagen y arruina el LCP medido por PageSpeed. El componente ya los lleva; tu trabajo es no romperlos al envolverlo en un padre con padding incoherente.

Implementación paso a paso

El grid mobile-first canónico para el catálogo. Tres breakpoints suficientes para vitrinas largas (≥8 elementos), con gap y container desde tokens:

/* Catálogo: 1 → 2 → 4 columnas */
.showcase {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-5);
}

@media (min-width: 640px)  { .showcase { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .showcase { grid-template-columns: repeat(4, 1fr); } }

Para vitrinas más cortas (3 o 6 elementos: servicios, cobertura), la rejilla de 4 deja una fila incompleta que se ve descuidada. Bajar el último breakpoint a 3 columnas mantiene el ritmo cuadrado:

/* Vitrinas cortas: 1 → 2 → 3 columnas */
.grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-5);
}

@media (min-width: 640px)  { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); } }

El frontmatter de cada item en una colección lleva la imagen con su ruta absoluta. El schema Zod fuerza el formato /images/... y rechaza cualquier otro:

---
title: "Cascos de seguridad industrial"
description: "Cascos homologados para industria pesada, ligeros y con barboquejo ajustable. Stock para entrega 24 h en CDMX."
category: "equipos"
image: "/images/productos/cascos-seguridad-industrial.avif"
order: 1
---

El componente recibe esa imagen junto con index, que decide el modo de carga. Las cuatro primeras tarjetas del grid reciben i ‹ 4 y por lo tanto loading="eager"; el resto cae en loading="lazy":

---
import { getCollection } from 'astro:content'
import CategoryCard from '@components/CategoryCard.astro'

const productos = (await getCollection('productos'))
  .filter((p) => !p.data.draft)
  .sort((a, b) => a.data.order - b.data.order)
---

<section class="container">
  <div class="showcase">
    {productos.map((p, i) => (
      <CategoryCard
        label={p.data.title}
        href={`/productos/${p.id}`}
        image={p.data.image}
        imageAlt={p.data.title}
        blurb={p.data.description}
        ctaLabel="Ver catálogo"
        index={i}
      />
    ))}
  </div>
</section>

<style>
  .container { max-width: var(--container-max); margin-inline: auto; padding-inline: var(--container-px); }
  .showcase { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); }
  @media (min-width: 640px)  { .showcase { grid-template-columns: repeat(2, 1fr); } }
  @media (min-width: 1024px) { .showcase { grid-template-columns: repeat(4, 1fr); } }
</style>

Tabla comparativa

BreakpointAncho objetivoContainerColumnas (catálogo)Decisión clave
380 pxiPhone SE, Android chico100 %, gutter doble1Validar que el CTA no se corte
480 pxPhablet básico100 %, gutter doble1Sigue stack vertical; aún no entra 2.ª col
640 pxTablet vertical100 %, gutter normal2Primera transición a multi-columna
768 pxTablet horizontal / laptop chica90 %, gutter normal2Container cambia de 100 % a 90 %
1024 pxDesktop90 %, gutter normal4Último salto; vitrina al máximo

Patrones avanzados

Breakpoints reales del proyecto. Los cinco breakpoints —380, 480, 640, 768, 1024— no son arbitrarios. 380 cubre iPhone SE y Android chico (el peor caso real del público mexicano industrial); 480 cubre phablets; 640 abre la transición a 2 columnas; 768 cambia el container de 100 % a 90 % porque ahí ya hay tablet horizontal con espacio para márgenes; 1024 abre las 4 columnas del catálogo. Saltarse cualquiera de los dos primeros se nota cuando un cliente manda un screenshot de un dispositivo «raro» que en realidad es el suyo.

--container-px fluido para evitar bordes pegados. El error clásico es declarar padding-inline: 16px fijo. En 1024 px se ve elegante; en 380 px las tarjetas tocan los bordes del cristal. El token --container-px se redefine por breakpoint: más generoso en móvil (donde el dedo necesita zona de toque) y estable en desktop. La regla de oro: en pantallas ≤768, el container ocupa el 100 % y el padding interno hace el trabajo del margen.

aspect-ratio para reservar el hueco de la imagen. El componente declara aspect-ratio: 16 / 10 en .ccard__media y width="640" height="400" en el ‹img›. Eso significa que el navegador conoce las proporciones antes de descargar el archivo y reserva el espacio exacto, evitando el reflujo que caracteriza a los sitios mal optimizados. PageSpeed lo mide como CLS; el visitante lo percibe como «este sitio se siente sólido».

Lazy loading inteligente con index. El componente decide entre eager y lazy con const eager = index ‹ 4. La regla del padre es pasar index=❴i❵ del map, no inventarlo: las cuatro primeras tarjetas son el LCP del fold; las demás esperan al scroll. Pasar index=❴0❵ a todas mata el LCP; pasar index=❴99❵ a las primeras hace que se vea una vitrina en blanco mientras cargan.

Prevención de CLS al cambiar de columnas. Cada salto de columnas modifica el ancho disponible de cada tarjeta. Si la imagen no respeta aspect-ratio, ese cambio reflowa todo el grid hacia abajo —los textos saltan, los chips brincan—. La combinación width + height intrínsecos + aspect-ratio en el contenedor garantiza que el grid solo se rearme horizontalmente, sin desplazamientos verticales que distraigan al ojo.

Checklist

  • El grid padre arranca en 1 columna (grid-template-columns: 1fr) sin condiciones.
  • Los breakpoints suben con min-width (mobile-first), nunca con max-width.
  • gap usa un token (var(--sp-5)), no un valor en px duro.
  • El .container aplica max-width: var(--container-max) y padding-inline: var(--container-px).
  • Las cuatro primeras tarjetas reciben index=❴i❵ con i ‹ 4 para LCP.
  • Cada ‹img› lleva width y height intrínsecos (el componente ya lo hace; no lo envuelvas en un wrapper que los borre).
  • La imagen es AVIF optimizado a 1280 px, no JPG sin compresión.
  • Probaste en 380 px reales o en DevTools con el viewport exacto (no en una ventana de Chrome de 1200 px arrastrada hasta 380).

Preguntas frecuentes

¿Por qué mobile-first y no desktop-first? Porque los min-width se acumulan en una sola dirección (el caso base es el teléfono) y no necesitas anular reglas. Con max-width cada breakpoint pelea contra el anterior y termina plagado de !important. El componente y los tokens están escritos para que el caso base —móvil— sea el más simple, y el desktop crezca desde ahí.

¿Puedo poner 5 columnas en pantallas grandes? Sí, agregando un breakpoint nuevo (por ejemplo @media (min-width: 1440px)), pero antes piensa si las tarjetas siguen viéndose grandes o si pierden masa visual. La rejilla 1→2→4 da 360 px de ancho por tarjeta en 1440; pasar a 5 las baja a 280 y la foto pierde fuerza. Mejor mantener 4 y subir el max-width del container si quieres aprovechar el espacio.

¿Cómo evito que la última fila quede con una sola tarjeta huérfana? Eligiendo el grid según el número de elementos: 1→2→4 para 4, 8, 12…; 1→2→3 para 3, 6, 9, 12. Si la cantidad es variable (una colección que crece), 1→2→3 es más tolerante porque tolera múltiplos de 3 y 6, ambos comunes en blogs.

¿Necesito un wrapper extra para el container? No. El ‹section› lleva la clase .container y dentro va directamente el .showcase. Anidar ‹div class="wrapper"›‹div class="container"›…‹/div›‹/div› agrega DOM inútil y suele meter padding duplicado que rompe el cálculo de columnas en breakpoints intermedios. La regla: un solo nivel de container por sección.

¿Por qué la imagen pesa tanto cuando uso JPG? Porque AVIF no es opcional. La diferencia entre un JPG a 80 kB y un AVIF a 25 kB en una vitrina de 12 tarjetas son 660 kB ahorrados —diez segundos en una conexión 3G real—. La receta del proyecto es ImageMagick a 1280 px de ancho y calidad 50; el grano es invisible en tarjetas de 360 px de ancho final.

Un grid responsive no es magia: son cinco breakpoints elegidos a propósito, un container fluido que sabe cuándo soltar el 90 % y volverse 100 %, y una imagen que declara su tamaño antes de cargar. Con eso y el componente CategoryCard agnóstico al grid, una vitrina se ve igual de sólida en un SE viejo que en un MacBook Pro 16.

Sigue leyendo

¿Listo para dar el siguiente paso?

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

¿Necesitas ayuda?