wandres.dev
ANIDAMIENTO NATIVO · Nesting sin preprocesador

Anidamiento nativo: la sintaxis y cómo lo procesa el motor

Qué es exactamente una regla anidada, por qué el orden entre declaraciones y reglas hijas importa, dónde se puede anidar y qué significa que todo esto ocurra en el navegador y no en el build.

⏱ 16 min

El anidamiento llevaba una década en la lista de peticiones y llegó tarde a propósito. Lo que lo volvió urgente no fue la comodidad de escribir menos, ni el deseo de imitar a Sass: fueron las reglas condicionales. Cuando una consulta de contenedor tiene que ir pegada al componente que consulta, y una capa tiene que envolver a un puñado de reglas relacionadas, escribir todo eso en el nivel superior deja de ser incómodo y pasa a ser inmanejable.

🎯 Al terminar esta lección sabrás
  • Escribir reglas anidadas con la sintaxis correcta y saber qué produce cada una.
  • Predecir el resultado cuando hay declaraciones antes y después de una regla anidada.
  • Enumerar dónde se puede anidar y dónde no.
  • Explicar qué implica que el anidamiento se resuelva en el navegador y no en la compilación.

Una regla dentro de otra

Una regla de estilo puede contener otras reglas de estilo. El bloque interior se interpreta en relación con el selector exterior.

.card {
  border: 1px solid;
  border-radius: 0.75rem;

  & > img { aspect-ratio: 16 / 9; }
  &:hover { border-color: currentColor; }
  & .titulo { font-weight: 600; }
}

Eso equivale exactamente a estas cuatro reglas escritas por separado:

.card { border: 1px solid; border-radius: 0.75rem; }
.card > img { aspect-ratio: 16 / 9; }
.card:hover { border-color: currentColor; }
.card .titulo { font-weight: 600; }

El símbolo & se llama selector de anidamiento y representa al selector exterior. Aparece donde tú lo pongas y tantas veces como quieras, incluido en el medio o al final:

.tema-oscuro {
  /* El exterior va delante: .tema-oscuro .boton */
  & .boton { --fondo: black; }
}

.boton {
  /* El exterior va detras: .tema-oscuro .boton, escrito desde el otro lado */
  .tema-oscuro & { --fondo: black; }

  /* Compuesto con otra clase: .boton.grande */
  &.grande { padding-block: 0.75rem; }
}

Fíjate en la diferencia entre & .grande y &.grande. Con espacio es un descendiente; sin espacio es un compuesto sobre el mismo elemento. Es el mismo espacio que separa .a .b de .a.b en CSS normal, y el mismo despiste que provoca desde 1996.

El orden importa

Un bloque anidado puede mezclar declaraciones y reglas hijas, y las declaraciones se aplican en el orden en que aparecen, no antes de todo.

.boton {
  color: blue;

  &:hover { color: green; }

  /* Esta declaracion va DESPUES de la regla anidada. */
  color: red;
}

El color base es rojo, porque la segunda declaración de color gana por orden de aparición dentro del mismo origen y la misma especificidad. Ese comportamiento parece obvio y no lo era: las primeras implementaciones elevaban todas las declaraciones por encima de las reglas anidadas, con lo que el ejemplo anterior daba azul. La especificación se corrigió y los motores se alinearon después, así que hoy el orden de escritura es el orden de aplicación.

De ahí sale una regla de higiene que te ahorra depender de en qué versión se arregló cada motor: pon todas las declaraciones primero y las reglas anidadas después. No pierdes nada, se lee mejor, y el resultado es idéntico en cualquier implementación.

⚠️
La coma dentro del anidamiento no se distribuye como crees

Un selector con comas dentro de un bloque anidado se comporta como una unidad agrupada, no como reglas independientes. .card { & h2, & h3 { ... } } produce :is(.card) :is(h2, h3), y eso tiene consecuencias de especificidad que verás en dos lecciones. La versión sin sorpresas es agrupar tú mismo: & :is(h2, h3).

Dónde se puede anidar

Dentro de una regla de estilo puedes poner otras reglas de estilo y también reglas condicionales. Esto último es lo que justifica la característica entera:

.card {
  display: grid;
  gap: 1rem;

  @media (min-width: 48rem) {
    grid-template-columns: 12rem 1fr;
  }

  @container (min-inline-size: 30rem) {
    padding: 2rem;
  }

  @supports (corner-shape: squircle) {
    border-radius: 1.5rem;
  }
}

Antes del anidamiento, esas tres condiciones tenían que vivir lejos del componente, cada una en su bloque, repitiendo el selector. Con el componente y sus condiciones juntos, mover el componente de sitio deja de ser una operación arqueológica.

Las reglas condicionales anidadas se comportan como si envolvieran una regla con el selector exterior implícito. Es decir, el bloque de arriba equivale a @media (min-width: 48rem) { .card { grid-template-columns: 12rem 1fr; } }.

Dónde no se puede anidar: dentro de @keyframes, cuyos bloques solo admiten declaraciones, y dentro de @font-face, @property o @counter-style, que son descriptores y no reglas de estilo. Y un apunte histórico por si te lo encuentras en material antiguo: los primeros borradores incluían una regla @nest para los casos en que el & no iba al principio. Se eliminó cuando se relajó la sintaxis; si ves @nest en un artículo, el artículo es anterior a 2023.

Esto ocurre en el navegador

La diferencia más importante con un preprocesador no es sintáctica: es cuándo pasa. Sass resuelve el anidamiento en tu máquina y entrega al navegador un archivo plano donde no queda rastro de la estructura. El anidamiento nativo lo resuelve el motor, y la estructura sobrevive hasta el CSSOM.

const hoja = document.styleSheets[0];
const regla = hoja.cssRules[0];        // la regla .card
console.log(regla.cssRules.length);    // las reglas anidadas dentro de ella

Eso tiene tres consecuencias prácticas. En las herramientas de desarrollo ves la regla anidada tal como la escribiste, con su contexto, en lugar del selector expandido que te devolvía un mapa de fuentes. Cualquier código que recorra hojas de estilo —un extractor de CSS crítico, una herramienta de análisis, un tematizador en tiempo de ejecución— tiene que estar preparado para recursión, porque una regla de estilo ya no es una hoja del árbol. Y el archivo que envías por la red es más pequeño, porque el selector exterior aparece una vez y no una vez por regla hija; con gzip la diferencia se estrecha, pero no desaparece.

Y hay una consecuencia menos obvia y más profunda: como el motor conoce la relación padre-hijo entre reglas, & no es una sustitución de texto sino una referencia al selector exterior, evaluada con las reglas del lenguaje. Eso es lo que produce las diferencias con Sass que verás en esta misma serie, y explica por qué algunas cosas que funcionaban en el preprocesador no funcionan aquí.

El anidamiento no llegó por comodidad, llegó por necesidad

Durante años, la petición de anidamiento nativo se despachaba con un argumento razonable: es azúcar sintáctico, ya existen preprocesadores, y meter azúcar en la plataforma tiene un coste permanente que hay que justificar. El argumento era correcto mientras CSS no tuvo reglas condicionales dependientes del contexto. Lo que cambió el cálculo fue @container. Una consulta de contenedor es, por definición, una condición sobre el entorno inmediato de un componente concreto, y no tiene ningún sentido escribirla lejos de ese componente: no existe un lugar central donde agrupar “todas las consultas de contenedor” como sí existía para agrupar los puntos de ruptura de una hoja responsive, porque cada consulta habla de un contenedor distinto. Lo mismo pasa con @scope, cuyo bloque solo tiene sentido leído junto a la raíz que declara. Sin anidamiento, adoptar estas dos características obligaba a repetir el selector del componente en cada condición y a esparcir su definición por el archivo, que es exactamente lo contrario de lo que ambas prometían. Por eso el anidamiento nativo no es la respuesta tardía a una petición de comodidad, sino una pieza de infraestructura que las características de los últimos cinco años necesitaban para ser usables. Y por eso el criterio para decidir si anidar algo no debería ser “queda más bonito”, sino “esta regla solo tiene sentido leída junto a su padre”. Con ese criterio, anidar condiciones es casi siempre correcto y anidar estructura casi siempre no lo es.

⚔️ Traduce en las dos direcciones
  1. Expande a mano a reglas planas un bloque anidado de tres niveles con & en posiciones distintas.
  2. Escribe el caso donde & .x y &.x producen resultados diferentes y compruébalo.
  3. Coloca una declaración después de una regla anidada y verifica en el inspector cuál gana.
  4. Anida @media, @container y @supports dentro de un mismo componente y comprueba el resultado en el CSSOM.
  5. Recorre document.styleSheets con un script que cuente reglas y arréglalo para que baje por las anidadas.