wandres.dev
ANIDAMIENTO NATIVO · Nesting sin preprocesador

El ampersand implícito y la regla del selector relativo

Por qué un selector anidado siempre lleva un & aunque no lo escribas, qué pasó con los selectores de tipo, y qué significa exactamente que & equivalga a :is() del selector padre.

⏱ 16 min

Escribir & delante de cada regla anidada era obligatorio en las primeras implementaciones, y no por capricho de la especificación sino por una limitación del analizador sintáctico. Cuando esa limitación se resolvió, la restricción cayó y el anidamiento pasó a leerse como cualquiera esperaba. Pero el & sigue estando ahí aunque no lo escribas, y saber exactamente en qué se convierte es lo que separa a quien usa el anidamiento de quien lo entiende.

🎯 Al terminar esta lección sabrás
  • Explicar qué añade el motor cuando un selector anidado no lleva &.
  • Contar por qué los selectores de tipo estuvieron prohibidos y cuándo dejaron de estarlo.
  • Traducir cualquier regla anidada a su forma expandida con :is().
  • Justificar por qué ninguna regla anidada puede escapar de su padre.

El ampersand implícito

Un selector dentro de un bloque anidado no se interpreta como un selector normal: se interpreta como un selector relativo, igual que el argumento de :has(). Puede empezar por un combinador, y si no empieza por ninguno se asume el de descendiente. En ambos casos, el ancla es el selector padre.

/* Estas tres parejas son equivalentes. */
.card { .titulo { } }          /*  ==  .card { & .titulo { } }  */
.card { > img { } }            /*  ==  .card { & > img { } }    */
.card { + .card { } }          /*  ==  .card { & + .card { } }  */

Cuando el & va a ir al principio y separado por un espacio, puedes omitirlo. Cuando quieras ponerlo en cualquier otro sitio, tienes que escribirlo:

.boton {
  /* Compuesto: hace falta el & para que no haya espacio. */
  &.primario { }

  /* Estado: hace falta el & por la misma razon. */
  &:hover { }

  /* El padre a la derecha: obviamente hace falta. */
  .tema-oscuro & { }
}

La regla mnemotécnica es de una línea: el & solo es opcional cuando lo que quieres es un descendiente. En cualquier otra posición, escríbelo.

Selectores de tipo y la restricción que se levantó

Las primeras implementaciones rechazaban esto:

article {
  span { color: crimson; }   /* rechazado en 2023 */
}

La razón no era semántica sino de análisis. Al leer el interior de un bloque, el analizador encuentra un identificador —span— y tiene que decidir si está empezando una declaración o una regla anidada. Ambas empiezan igual. color y span son indistinguibles como token, y la diferencia solo aparece más adelante: una declaración lleva dos puntos y un valor; una regla lleva una llave. Un analizador que no puede mirar hacia delante sin límite se atasca.

La solución fue admitir esa mirada hacia delante en la especificación de sintaxis: al abrir un bloque anidado se intenta leer una declaración, y si el intento falla se vuelve atrás y se lee una regla cualificada. Con eso, la prohibición dejó de tener sentido y los motores la relajaron a lo largo de 2023. Desde entonces el ejemplo de arriba funciona en los cuatro.

Queda un residuo de la ambigüedad que conviene conocer. Si el nombre del elemento que anidas coincide con el de una propiedad, el analizador puede intentar leerlo como declaración antes de descartar esa vía. No es frecuente, pero existe, y la cura es trivial:

/* Ambiguo a primera vista: color tambien es el nombre de una propiedad. */
.grafico { color:hover { } }

/* Sin ambiguedad: el & deja claro que empieza una regla y no una declaracion. */
.grafico { & color:hover { } }

En la práctica el consejo es más simple: si anidas un selector de tipo y tienes cualquier duda, pon el & delante. No cuesta nada y elimina la clase entera de problemas.

Qué significa exactamente el ampersand

Aquí está la pieza que hay que interiorizar: & no es una sustitución de texto. Equivale a :is() aplicado a la lista completa de selectores del padre.

.a, .b {
  & .c { color: red; }
}

No produce .a .c, .b .c. Produce esto:

:is(.a, .b) .c { color: red; }

Los dos encajan con los mismos elementos, así que la diferencia parece cosmética. No lo es, y la razón la verás con números en la lección siguiente. Lo que importa ahora es la mecánica: el padre entra como una unidad, no distribuido.

flowchart TB
A[Selector escrito dentro del bloque] --> B{Empieza por combinador}
B -- Si --> C[Se ancla al padre con ese combinador]
B -- No --> D[Se antepone el descendiente]
C --> E[El ancla es el ampersand]
D --> E
E --> F[El ampersand se sustituye por is de la lista del padre]
style F fill:#cba6f7,color:#11111b
style E fill:#89b4fa,color:#11111b

Del hecho de que & sea una referencia y no un texto salen tres comportamientos que sorprenden la primera vez.

Repetirlo se compone sobre sí mismo. .card { & & { } } produce :is(.card) :is(.card), es decir, una tarjeta dentro de otra tarjeta. Y .card { && { } } produce :is(.card):is(.card), el mismo elemento comprobado dos veces, que encaja con lo mismo pero pesa el doble.

Funciona dentro de otras pseudo-clases. Puedes escribir & dentro de :is(), :not(), :where() y :has() sin ceremonias:

.panel {
  /* Un panel que no esta dentro de otro panel. */
  &:not(.panel &) { padding: 2rem; }

  /* Un panel que contiene una imagen. */
  &:has(> img) { padding-block-start: 0; }
}

Anidar varios niveles compone las referencias en cadena. Cada nivel resuelve su & contra el nivel inmediatamente superior ya resuelto, no contra el texto original.

El ampersand fuera de todo

Hay un caso al que casi nadie llega y que ilumina el diseño: & también es válido fuera de cualquier anidamiento. Ahí no hay padre al que referirse, así que la especificación define que representa a :scope, y en una hoja de estilos normal :scope es el elemento raíz.

/* En una hoja de estilos normal, esto encaja con html. */
& { --tono: 240; }

No es un truco útil por sí mismo —escribe :root y se acabó—, pero explica la coherencia del diseño: & siempre significa “el ámbito en el que estoy”, y el anidamiento simplemente redefine cuál es ese ámbito. Cuando llegues a @scope, el mismo & volverá a significar algo distinto por la misma razón.

💡
Cómo depurarlo sin adivinar

Las herramientas de desarrollo muestran el selector resuelto de la regla anidada junto al que escribiste. Si una regla no aplica y no entiendes por qué, mira el selector resuelto antes que ninguna otra cosa: nueve de cada diez veces el problema es un & que faltaba y un descendiente que apareció donde querías un compuesto.

Ninguna regla anidada puede escaparse de su padre

De la regla del selector relativo se deriva una garantía que no se enuncia casi nunca y que vale más que toda la comodidad sintáctica junta: el selector de una regla anidada contiene necesariamente al selector del padre. No hay forma de escribir dentro de un bloque una regla que aplique a elementos que no tengan ninguna relación con él. Puedes poner el padre delante, detrás, compuesto o dentro de un :has(), pero está. Compáralo con Sass, que ofrece @at-root precisamente para romper esa garantía y emitir una regla al nivel superior desde dentro de un bloque anidado; el anidamiento nativo no tiene equivalente y no lo tendrá, porque la ausencia de esa escapatoria es la propiedad que hace el sistema analizable. Las consecuencias son muy concretas y muy grandes cuando el proyecto crece. Borrar el bloque de un componente borra todas sus reglas, con certeza, sin tener que buscar por el resto de la hoja. Un extractor de CSS crítico puede descartar el subárbol entero de un componente que no aparece en la página sin analizar cada regla hija. Una herramienta de análisis puede afirmar que dos componentes no interfieren con solo mirar sus selectores raíz. Y sobre todo, tú puedes leer un bloque anidado y saber que lo que estás viendo es completo: no hay una regla escondida dentro que afecte a algo de fuera. En un lenguaje cuyo pecado original es que todo es global, el anidamiento nativo introduce la primera construcción sintáctica que garantiza contención, y esa garantía se la debe a haber prohibido la escapatoria que el preprocesador sí permite.

⚔️ Resuelve el ampersand
  1. Expande .a, .b { & > .c { } } a su forma con :is() y explica por qué no es .a > .c, .b > .c.
  2. Escribe con anidamiento la regla “un .panel que no esté dentro de otro .panel”.
  3. Explica qué encaja .card { && { } } y en qué se diferencia de .card { & & { } }.
  4. Anida tres niveles y escribe a mano el selector resuelto del más profundo antes de mirarlo en el inspector.
  5. Busca en tu proyecto una regla que Sass emitía con @at-root y decide dónde tiene que vivir sin esa escapatoria.