wandres.dev
UNIDADES DE CONTENEDOR · cqw, cqi, cqmin y compañía

El componente verdaderamente portable

Una tarjeta completa que funciona a 260px, a 500px y a 900px sin una sola media query: el CSS entero, las decisiones de cada tramo y cómo se prueba que de verdad es portable.

⏱ 20 min

Todo lo de los dos niveles anteriores confluye en un solo objetivo: un componente que se pueda colocar en cualquier hueco sin que su autor conozca ese hueco y sin que su consumidor tenga que elegir una variante. Esta lección es el ejemplo completo, con el CSS que puedes copiar, la justificación de cada decisión, y el protocolo de prueba que separa un componente que parece portable de uno que lo es.

🎯 Al terminar esta lección sabrás
  • Escribir un componente completo que combine consultas de contenedor y unidades de contenedor.
  • Decidir qué cambios corresponden a una consulta discreta y cuáles a una escala continua.
  • Exponer la configuración del componente como custom properties en su envoltorio.
  • Probar la portabilidad con un protocolo que descubra los fallos que la vista pasa por alto.

El componente completo

El marcado

<div class="ficha-wrap">
  <article class="ficha">
    <img class="ficha__img" src="/portada.jpg" alt="">
    <div class="ficha__texto">
      <p class="ficha__meta">Análisis · 12 min</p>
      <h3 class="ficha__titulo">El coste real de una consulta de contenedor</h3>
      <p class="ficha__entrada">Medimos qué cuesta declarar mil contenedores y qué cuesta no declarar ninguno.</p>
      <div class="ficha__acciones">
        <a class="ficha__cta" href="/articulo/">Leer</a>
        <button class="ficha__guardar" type="button">Guardar</button>
      </div>
    </div>
  </article>
</div>

Un envoltorio, un artículo y dos zonas. El envoltorio no lleva estilo visual: es el contenedor de consultas y el punto donde el consumidor decide el ancho.

El CSS

.ficha-wrap {
  container: ficha / inline-size;
}

.ficha {
  --paso: clamp(0.7rem, 0.45rem + 1.1cqi, 1.6rem);
  --t-titulo: clamp(1.05rem, 0.85rem + 1.4cqi, 1.9rem);
  --t-cuerpo: clamp(0.9rem, 0.87rem + 0.15cqi, 1.0625rem);
  --radio: calc(var(--paso) * 0.65);

  display: grid;
  gap: var(--paso);
  padding: var(--paso);
  border: 1px solid color-mix(in oklch, currentColor 18%, transparent);
  border-radius: var(--radio);
  background: color-mix(in oklch, currentColor 4%, transparent);
}

.ficha__img {
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: calc(var(--radio) * 0.7);
}

.ficha__texto { display: grid; gap: calc(var(--paso) * 0.5); align-content: start; }

.ficha__meta {
  font-size: 0.8rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  opacity: 0.75;
}

.ficha__titulo {
  font-size: var(--t-titulo);
  line-height: 1.15;
  letter-spacing: -0.015em;
  text-wrap: balance;
  margin: 0;
}

.ficha__entrada {
  font-size: var(--t-cuerpo);
  line-height: 1.55;
  max-inline-size: 60ch;
  margin: 0;
}

.ficha__acciones {
  display: flex;
  flex-wrap: wrap;
  gap: calc(var(--paso) * 0.5);
  align-items: center;
  margin-block-start: calc(var(--paso) * 0.25);
}

.ficha__cta,
.ficha__guardar {
  min-block-size: 2.75rem;                     /* del dedo, constante */
  padding-inline: calc(var(--paso) * 0.8);
  display: inline-flex;
  align-items: center;
  border: 1px solid currentColor;              /* grosor constante */
  border-radius: calc(var(--radio) * 0.7);
  font-size: 0.9375rem;
}

/* Tramo medio: aparece la columna de imagen */
@container ficha (inline-size >= 26rem) {
  .ficha {
    grid-template-columns: minmax(9rem, 32%) minmax(0, 1fr);
    align-items: start;
  }
  .ficha__img { aspect-ratio: 4 / 3; block-size: 100%; }
}

/* Tramo ancho: mas peso a la imagen y la entrada respira */
@container ficha (inline-size >= 48rem) {
  .ficha { grid-template-columns: minmax(12rem, 38%) minmax(0, 1fr); }
  .ficha__entrada { font-size: calc(var(--t-cuerpo) * 1.05); }
  .ficha__meta { font-size: 0.875rem; }
}

/* Tramo estrecho: la entrada estorba */
@container ficha (inline-size < 17rem) {
  .ficha__entrada { display: none; }
  .ficha__acciones { flex-direction: column; align-items: stretch; }
}

Por qué cada decisión

Las escalas son continuas, los cambios de estructura son discretos. El tamaño del título, el padding y los radios varían suavemente con cqi porque no hay ningún ancho en el que “salten”: simplemente son más grandes cuanto más sitio hay. En cambio, pasar de una columna a dos es un cambio cualitativo y por eso vive en una @container. Mezclar los dos criterios —hacer discreto lo continuo o al revés— es el error más frecuente al construir estos componentes.

Los umbrales están en rem. 26rem y 48rem se mueven con el tamaño de fuente del usuario, con lo que quien lee con la letra grande obtiene la disposición apilada antes, que es justo lo que necesita.

minmax(0, 1fr) en la columna de texto. Sin ese mínimo cero, un título largo sin espacios o una URL empujarían la pista y desbordarían la tarjeta.

Ocultar la entrada por debajo de 17rem es una decisión de contenido, no de layout, y hay que tomarla con cuidado: es aceptable porque la entrada es un resumen redundante y el enlace sigue siendo alcanzable. Si en lugar de un resumen fuera el precio o la fecha, ocultarlo sería un error.

Configurar desde fuera

El componente queda parametrizable sin tocar su CSS, porque toda su configuración está en custom properties que el consumidor puede redefinir en el envoltorio:

.barra-lateral .ficha-wrap { --paso: clamp(0.5rem, 0.4rem + 0.8cqi, 1rem); }
.portada .ficha-wrap       { --paso: clamp(1rem, 0.7rem + 1.4cqi, 2rem); }

Este es el contrato completo del componente: recibe un ancho de su padre, admite unas cuantas custom properties de configuración, y no consulta nada global. Se puede documentar en cinco líneas y no tiene ninguna dependencia oculta sobre dónde está colocado.

💡
Qué NO poner en el envoltorio, y por qué el motivo ya no es el que era

Hasta 2024 la regla aquí era tajante: el envoltorio es contenedor de consultas, aplica containment de layout, y por tanto crea contexto de apilamiento y es bloque contenedor de los position: fixed de dentro. Esa regla ya no vale. Desde Chrome 129, Firefox 133 y Safari 18.4, container-type solo aplica containment de estilo y de tamaño y establece un contexto de formato independiente, así que un modal fijo dentro del envoltorio hoy se ancla al viewport sin problema.

La recomendación práctica no cambia, pero su motivo sí. Los modales, los menús desplegables y los tooltips siguen debiendo ir a la capa superior con dialog o con el atributo popover, y no porque el envoltorio los atrape, sino porque cualquier ancestro con transform, filter u overflow sí los atrapa y un componente portable no puede saber dónde lo van a colocar. La capa superior es la única posición que no depende del sitio.

Cómo probar que es portable de verdad

Mirar el componente en dos anchos no demuestra nada. El protocolo que sí descubre fallos tiene cinco pasos y se puede montar en una página de pruebas.

Barrido continuo. Coloca el componente en un contenedor redimensionable —un resize: horizontal con overflow: auto basta— y arrastra despacio de 200px a 1200px buscando saltos feos, textos que se solapan y momentos donde algo desborda. Los fallos casi nunca están en los anchos que diseñaste, están entre ellos.

Contenido extremo. Un título de una palabra y otro de treinta. Sin imagen. Sin entrada. Con una etiqueta de meta larguísima. La mitad de los fallos de un componente “responsive” son en realidad fallos de contenido inesperado.

Tipografía del usuario. Sube el tamaño de fuente por defecto del navegador al 200% sin usar el zoom de página. Todo lo que esté en rem debe crecer; lo que esté solo en cqi no crecerá y ahí verás si te faltó un término.

Contextos reales. Métela en una rejilla de tres columnas, en una barra lateral de 280px, en un modal de 640px y en la vista de impresión. Sin cambiar una línea de su CSS.

Recorrido de teclado. Tabula por el componente en el tramo estrecho y en el ancho. Si has cambiado la disposición, el orden de foco debe seguir teniendo sentido; y si has ocultado algo con display: none, comprueba que era prescindible y no un control.

La portabilidad no es una propiedad del componente: es una propiedad de su contrato

Cuando un componente resulta ser portable, la tentación es atribuirlo a las técnicas empleadas. Es al revés: las técnicas son consecuencia de haber definido un contrato estrecho, y el contrato es lo que hace el trabajo. Este componente es portable porque su contrato dice exactamente tres cosas —“dame un ancho”, “puedes ajustar estas custom properties”, “no consulto nada más”— y porque todo lo que no está en ese contrato es información que el componente ha renunciado a usar. Renunciar al viewport, a saber si es móvil, a saber en qué página está y a qué hay a su alrededor no es una limitación que las container queries te impongan: es la decisión de diseño que las hace valiosas. La prueba te la puedes hacer con cualquier componente que tengas escrito: enumera todo lo que necesita saber del mundo para verse bien. Cada elemento de esa lista es un sitio donde no se puede colocar sin comprobarlo antes, y la longitud de la lista es literalmente el coste de reutilizarlo. Los componentes que sobreviven a cinco años de producto no son los mejor escritos: son los que menos preguntas le hacen al mundo.

⚔️ Somete tu componente al protocolo
  1. Implementa la ficha completa y monta una página de pruebas con un contenedor redimensionable.
  2. Haz el barrido continuo de 200px a 1200px y anota cada ancho donde algo se ve mal.
  3. Sustituye el título por uno de una sola palabra muy larga y comprueba que no desborda.
  4. Sube la fuente base del navegador al 200% y verifica qué crece y qué no.
  5. Coloca la ficha en tres contextos distintos sin tocar su CSS y comprueba que los tres son aceptables.