wandres.dev
SELECTORES ESTRUCTURALES · nth-child of, rangos y estado

of S frente a nth-of-type: filtrar antes de contar

Por qué nth-child(2 of .x) no es lo mismo que .x:nth-child(2), qué se quedó corto en nth-of-type, cuánta especificidad aporta el argumento y qué idiomas nuevos desbloquea.

⏱ 16 min

Durante veinte años CSS solo supo contar hermanos de dos formas: todos, o los que compartían nombre de etiqueta. Contar únicamente los que llevan cierta clase era imposible, y el intento ingenuo —poner la clase delante del :nth-child()— produce un resultado distinto que parece un error del navegador y no lo es. La cláusula of S cierra el hueco, y de paso convierte a :nth-of-type() en un caso particular que ya no hacía falta.

🎯 Al terminar esta lección sabrás
  • Distinguir contar y luego filtrar de filtrar y luego contar.
  • Explicar por qué :nth-of-type() no admite of S y en qué lo deja eso.
  • Calcular la especificidad de un :nth-child() con cláusula of.
  • Escribir rayas de cebra y numeraciones que ignoren los elementos filtrados.

Filtrar y luego contar

Estas dos reglas se parecen y no significan lo mismo:

/* A: cuenta TODOS los hermanos, se queda con el segundo, y comprueba si tiene la clase. */
.destacado:nth-child(2) { }

/* B: se queda solo con los .destacado, y de esos coge el segundo. */
:nth-child(2 of .destacado) { }

Con este HTML, A no encaja con nada y B encaja con el tercer li:

<ul>
  <li class="destacado">uno</li>
  <li>dos</li>
  <li class="destacado">tres</li>
</ul>

El orden de las operaciones lo es todo. En A, el conjunto sobre el que se cuenta es la lista completa de hermanos y la clase es una comprobación posterior sobre el elemento ya elegido. En B, la clase construye primero una sublista y el conteo ocurre dentro de ella.

flowchart LR
subgraph A[Sin of]
  A1[Todos los hermanos] --> A2[Coger la posicion 2] --> A3[Comprobar la clase]
end
subgraph B[Con of]
  B1[Todos los hermanos] --> B2[Filtrar por la clase] --> B3[Coger la posicion 2 de la sublista]
end
style A3 fill:#f38ba8,color:#11111b
style B3 fill:#a6e3a1,color:#11111b

Formalmente, la cláusula of S sustituye la lista de hermanos por la lista de hermanos que encajan con S, y toda la aritmética An+B se aplica sobre esa lista. :nth-last-child() acepta la misma cláusula y cuenta la sublista desde el final.

/* El primero y el ultimo de una clase, sin saber cuantos hay. */
:nth-child(1 of .fila-visible) { border-block-start: 0; }
:nth-last-child(1 of .fila-visible) { border-block-end: 0; }

Ese par de reglas resuelve un problema que antes obligaba a marcar desde la plantilla: “quítale el borde al primero y al último de los que quedan tras filtrar”. No hay :first-child of; el idioma es :nth-child(1 of S).

nth-of-type: el caso particular que se quedó corto

:nth-of-type() cuenta entre los hermanos del mismo tipo de elemento, entendiendo tipo como nombre de etiqueta más espacio de nombres. Es útil en documentos donde la estructura la marca el HTML y no las clases.

/* El segundo parrafo del articulo, ignorando titulos y listas intercalados. */
article p:nth-of-type(2) { }

Lo que no puede hacer es filtrar por nada que no sea el tipo. :nth-of-type() no admite la cláusula of, y no la admitirá: sería redundante, porque :nth-child(An+B of tipo) ya expresa lo mismo y más. La equivalencia es exacta:

/* Estas dos lineas seleccionan el mismo elemento. */
p:nth-of-type(2) { }
p:nth-child(2 of p) { }

Visto así, :nth-of-type() es :nth-child(... of S) con S restringido al tipo del propio sujeto. Sigue en el lenguaje por compatibilidad y por brevedad, y no hay razón para dejar de usarlo cuando el filtro que quieres es exactamente el tipo. La regla de decisión es de una línea: si filtras por etiqueta, :nth-of-type(); si filtras por cualquier otra cosa, of S.

⚠️
of S no puede filtrar por lo que no es un selector

La sublista se construye con un selector, así que solo puedes filtrar por lo que un selector sabe ver: etiquetas, clases, atributos, estados. No puedes filtrar por visibilidad. :nth-child(odd of :not([hidden])) funciona porque hidden es un atributo; :nth-child(odd of :visible) no existe, y ningún truco lo sustituye, porque display: none es una decisión de estilo que se toma después de resolver los selectores. Si filtras una tabla ocultando filas con CSS, tus rayas de cebra se descuadran. Si la filtras poniendo un atributo o una clase, se recalculan solas.

La especificidad del argumento

La cláusula of no es gratis en la cascada. La especificidad de :nth-child() es la de una pseudo-clase, más —si hay cláusula of— la del selector más específico de S. Es la misma regla que ya conoces de :is() y de :has().

/* (0,1,0) de la pseudo-clase.  Total: (0,1,0) */
:nth-child(2) { }

/* (0,1,0) de la pseudo-clase + (0,1,0) de .destacado.  Total: (0,2,0) */
:nth-child(2 of .destacado) { }

/* (0,1,0) + (1,0,0) del identificador.  Total: (1,1,0) */
:nth-child(2 of #primero, .destacado) { }

Y la misma cura: si escribes CSS que otros van a sobrescribir, envuelve el filtro en :where() para que aporte cero.

tr:nth-child(odd of :where(:not([hidden]))) { background: canvas; }

Merece la pena comparar el coste real de las dos formas de escribir “el segundo destacado”, porque no solo difieren en semántica sino en peso: .destacado:nth-child(2) pesa (0,2,0) igual que :nth-child(2 of .destacado), así que la elección entre ambas nunca es una cuestión de especificidad. Es siempre una cuestión de qué pregunta estás haciendo.

Los idiomas que desbloquea

Rayas de cebra que sobreviven al filtrado. El caso canónico. Una tabla filtrable donde las filas descartadas llevan hidden:

tbody tr[hidden] { display: none; }

/* Alterna sobre las filas que quedan, no sobre las del DOM. */
tbody tr:nth-child(odd of :not([hidden])) {
  background: color-mix(in oklch, canvas 94%, currentColor);
}

Numeración de una sublista. Combinado con contadores, numera solo lo que cuenta:

ol { counter-reset: paso; }
li:not([hidden]) { counter-increment: paso; }
li:not([hidden])::marker { content: counter(paso) ". "; }

/* Y el ultimo visible cierra la lista sin separador. */
li:nth-last-child(1 of :not([hidden])) { border-block-end: 0; }

Separadores entre grupos. Insertar una regla cada cierto número de elementos de una categoría:

/* Una linea divisoria cada tres articulos destacados. */
:nth-child(3n of .destacado) { border-block-end: 2px solid; }

Bordes de una rejilla filtrada. El primero de cada fila cuando la fila la define una clase:

.grid > :nth-child(4n+1 of .visible) { border-inline-start: 0; }

Sobre el soporte: WebKit lo implementó muy pronto, en 2015, y estuvo casi una década siendo el único. Chrome lo envió en la versión 111 y Firefox en la 113, ambas en la primera mitad de 2023. Desde entonces está ampliamente disponible en los cuatro motores y no necesita fallback en 2026. Si tu proyecto todavía arrastra un compromiso con navegadores anteriores, la degradación es benigna: la regla se descarta entera y te quedas sin las rayas, no con las rayas mal puestas.

of S es la única vista que CSS sabe construir

Hay algo estructuralmente raro en of S que explica por qué tardó tanto y por qué se siente distinto al resto del lenguaje. Todos los demás selectores son predicados: dado un elemento, devuelven verdadero o falso, y se pueden evaluar mirando ese elemento y su entorno inmediato sin construir nada. of S no es un predicado: obliga al motor a materializar una colección —la sublista de hermanos que encajan con S— y a indexarla, porque sin la colección completa no se puede saber qué posición ocupa nadie dentro de ella. Es el único punto del lenguaje donde un selector crea una vista sobre el árbol en lugar de limitarse a interrogarlo. De ahí salen dos consecuencias prácticas. La primera es de rendimiento: cualquier cambio en un hermano que afecte a si encaja con S invalida el estilo de todos los hermanos posteriores, porque a todos se les ha movido el índice; es el mismo perfil de coste que tiene insertar un elemento en medio de una lista y no el de cambiar una clase. La segunda es de diseño: como la vista se construye a partir de un selector, todo lo que quieras usar como criterio de filtrado tiene que estar expresado en el DOM de forma declarativa y estable, no derivado del estilo. Por eso la disciplina correcta al montar filtros es marcar con atributos —hidden, data-*, aria-*— y dejar que display: none sea la consecuencia y nunca la causa. Quien filtra escondiendo con CSS descubre tarde que ha construido un sistema en el que la única fuente de verdad es invisible para los selectores.

⚔️ Cuenta lo que queda
  1. Explica con este HTML por qué .x:nth-child(2) y :nth-child(2 of .x) dan resultados distintos.
  2. Escribe la regla que da fondo alterno a las filas visibles de una tabla filtrada por atributo.
  3. Calcula la especificidad de tr:nth-child(2n of .fila[data-tipo="dato"]).
  4. Reescribe p:nth-of-type(3) con of S y comprueba que seleccionan lo mismo.
  5. Monta un filtro que oculte filas con display: none y otro que use hidden, y observa cuál rompe la cebra.