:target, :defined y dónde vive el estado
La URL como almacén de estado con :target, el destello de los componentes web resuelto con :defined, las trampas de :empty y :root, y un criterio para decidir dónde guardar cada estado.
Las pseudo-clases estructurales que quedan por ver no comparten tema: comparten una pregunta. Cada una expone en CSS un estado que vive fuera del árbol —en la URL, en el registro de componentes, en la ausencia de contenido— y cada una plantea la misma decisión de arquitectura: dónde conviene guardar cada hecho para que la interfaz pueda leerlo. Esa decisión es la que cierra el nivel.
- Usar
:targetpara conmutar interfaces con estado en la URL y conocer sus efectos secundarios. - Eliminar el destello inicial de los componentes web con
:defined. - Evitar las trampas de
:empty,:rooty:read-only. - Elegir con criterio entre URL, atributo, estado nativo y JavaScript para guardar un estado.
La URL como estado: :target
:target encaja con el elemento cuyo identificador coincide con el fragmento de la URL. Es la única pseudo-clase de CSS que lee algo que no está en el documento.
<nav>
<a href="#panel-a">Panel A</a>
<a href="#panel-b">Panel B</a>
</nav>
<section id="panel-a" class="panel">Contenido de A</section>
<section id="panel-b" class="panel">Contenido de B</section>
.panel { display: none; }
.panel:target { display: block; }
/* Primer panel por defecto: si ninguno es objetivo, muestra el primero. */
body:not(:has(.panel:target)) :nth-child(1 of .panel) { display: block; }
/* El enlace del panel activo se marca solo. El nav va ANTES en el DOM,
asi que el hermano posterior no sirve: hace falta mirar desde la raiz. */
nav a { text-decoration: none; }
body:has(#panel-a:target) nav a[href="#panel-a"],
body:has(#panel-b:target) nav a[href="#panel-b"] { font-weight: 700; }
Lo que se gana con esto no es ahorrarse JavaScript, que también: es que el estado se vuelve compartible, marcable como favorito y reversible con el botón atrás, gratis y sin escribir una línea de gestión de historial. Un enlace a #panel-b abre la página con ese panel activo.
Lo que se paga son tres efectos secundarios que hay que conocer antes de adoptarlo. La navegación por fragmento desplaza el documento hasta el elemento, lo cual se corrige con scroll-margin-block-start si tienes una cabecera pegajosa. Añade una entrada al historial por cada cambio, así que un panel con seis pestañas deja seis entradas y el botón atrás deja de significar “vuelve a la página anterior”. Y mueve el punto de partida de la navegación por teclado al elemento apuntado, lo cual normalmente es lo que quieres, pero conviene comprobarlo.
Para “cerrar” el estado hay dos opciones: un enlace a otro fragmento que no exista como panel, o un enlace a la ruta sin fragmento. La segunda es más limpia porque deja la URL igual que al principio.
/* Enlace de cierre: apunta a la propia ruta sin fragmento. */
.cerrar { display: none; }
.panel:target .cerrar { display: inline; }
Existe :target-within en la especificación, para el ancestro que contiene al objetivo, pero su soporte no está a la altura. Usa :has(:target), que hace lo mismo y hoy funciona:
.contenedor:has(:target) { --hay-panel-abierto: 1; }
:defined y el destello de los componentes web
Cuando el navegador encuentra <mi-grafico> antes de que el JavaScript lo haya registrado con customElements.define, no lo ignora: lo trata como un elemento desconocido, le da display: inline y pinta su contenido interno tal cual. El resultado es un destello de contenido sin estilo que dura desde el primer pintado hasta que el módulo se ejecuta.
:defined encaja con todo elemento definido: los estándar de HTML siempre, y los personalizados solo después de registrarse. Su negación es el punto de enganche:
/* Reserva el hueco y oculta el interior hasta que el componente exista. */
mi-grafico:not(:defined) {
display: block;
min-block-size: 12rem;
background: color-mix(in oklch, canvas 92%, currentColor);
border-radius: 0.5rem;
}
mi-grafico:not(:defined) > * { visibility: hidden; }
Dos matices. :defined encaja con todos los elementos estándar, así que div:not(:defined) no encaja nunca; solo tiene sentido sobre nombres con guion. Y prefiere visibility: hidden a display: none para el contenido interior si ese contenido es el que el componente va a leer y proyectar, porque algunos componentes miden su luz antes de renderizar.
Desde JavaScript, la contraparte es customElements.whenDefined(), que devuelve una promesa. El patrón robusto usa ambas: CSS se ocupa del intervalo hasta la definición, y JavaScript coordina lo que tenga que ocurrir después.
await customElements.whenDefined('mi-grafico');
document.documentElement.dataset.componentesListos = '';
Los estructurales que quedan
:empty encaja con elementos sin hijos. El detalle que arruina su uso ingenuo es el espacio en blanco: un salto de línea y una indentación entre las etiquetas de apertura y cierre son un nodo de texto, y durante mucho tiempo bastaban para que un elemento dejase de estar vacío. Selectors 4 redefinió la pseudo-clase para que el espacio no cuente, pero la adopción no ha sido uniforme y no conviene apoyarse en el matiz. Si lo que quieres comprobar es que no hay elementos hijos, hay una alternativa exacta y sin ambigüedades:
/* Depende de como el generador de HTML formatee el espacio. Fragil. */
.panel:empty { display: none; }
/* No depende del espacio en blanco: pregunta por hijos de tipo elemento. */
.panel:not(:has(*)) { display: none; }
:root encaja con el elemento raíz del documento, que en HTML es siempre html. Su interés es la especificidad: :root pesa (0,1,0) como cualquier pseudo-clase, mientras que html pesa (0,0,1) como cualquier etiqueta. Por eso las custom properties globales se declaran en :root y no en html: con una décima parte de esfuerzo ganan a cualquier declaración de tipo que alguien haya dejado por ahí. La contrapartida es que sobrescribir :root desde otro sitio también cuesta más.
:read-only merece un aviso propio porque su nombre engaña: encaja con cualquier elemento que no sea editable, no solo con los controles que llevan el atributo. Un párrafo cualquiera es de solo lectura. Escribir :read-only { opacity: 0.6 } sin acotar atenúa el documento entero.
:dir() y :lang() cierran el grupo. :dir(rtl) encaja según la dirección resuelta del texto, que puede venir de un atributo del elemento, de un ancestro o de la detección automática con dir="auto"; hacerlo con [dir="rtl"] solo captura el caso explícito. :lang(es) encaja con el idioma heredado y admite comodines, así que :lang(es) cubre también es-MX sin escribirlo.
Elegir dónde vive el estado
Al terminar este nivel tienes cuatro sitios donde puede vivir un estado que la interfaz necesita leer, y elegir mal es la causa de la mitad de la complejidad accidental de una aplicación.
flowchart TB
A[Un estado que la interfaz debe reflejar] --> B{Debe poder compartirse por enlace}
B -- Si --> U[Fragmento de la URL con target]
B -- No --> C{Existe un control nativo que lo represente}
C -- Si --> N[Estado nativo con checked open o user-invalid]
C -- No --> D{Es un hecho estructural del arbol}
D -- Si --> H[Selector relacional o estructural]
D -- No --> J[Atributo escrito desde JavaScript]
style U fill:#a6e3a1,color:#11111b
style N fill:#a6e3a1,color:#11111b
style H fill:#89b4fa,color:#11111b
style J fill:#f9e2af,color:#11111bLa escala va de más a menos deseable de arriba abajo, y la razón es la cantidad de código que tienes que escribir y mantener para que el estado siga siendo correcto. El fragmento de la URL y el estado nativo se sincronizan solos con el historial, con la restauración de sesión y con la tecnología de asistencia. Un atributo escrito desde JavaScript no: hay que ponerlo, quitarlo, restaurarlo al volver atrás y mantenerlo coherente con lo que anuncia el lector de pantalla.
La regla práctica: no muevas un estado a JavaScript hasta haber comprobado que ninguna de las tres capas anteriores lo cubre. Y cuando no quede más remedio, escríbelo como atributo en el DOM en lugar de como variable en memoria, para que la cascada pueda verlo y no tengas que propagarlo a mano.
Hay un criterio que discrimina mejor que ningún otro a la hora de elegir entre :target y las demás opciones, y casi nunca se enuncia: :target es un estado singular y global. Solo puede haber un fragmento en la URL, así que solo un elemento del documento puede ser objetivo a la vez. Eso lo hace perfecto para preguntas del tipo “cuál de estos paneles está abierto”, “qué pestaña está activa”, “qué imagen se está viendo ampliada”, donde la exclusividad no es una limitación sino exactamente la semántica que quieres, y la obtienes sin escribir el código que apaga los demás. Y lo hace completamente inservible para “qué casillas están marcadas” o “qué filtros están activos”, donde el estado es un conjunto: intentar codificar un conjunto en el fragmento te lleva a inventar una serialización propia, a parsearla desde JavaScript, y a haber perdido por el camino la única ventaja que tenía :target, que era no tener que escribir ese código. La misma pregunta de cardinalidad ordena el resto de la escala: los estados nativos como :checked son por elemento y múltiples, el atributo name compartido en varios details convierte lo múltiple en exclusivo dentro de un grupo, y :has() te permite agregar muchos estados por elemento en un hecho único sobre el contenedor. Cuando dudes dónde poner un estado, pregúntate primero cuántos valores simultáneos admite y en qué ámbito son únicos; el mecanismo correcto casi siempre sale solo de esa respuesta, y elegirlo bien te ahorra la capa de sincronización que de otro modo acabarás escribiendo.
- Monta un conmutador de paneles con
:targetque muestre el primero por defecto. - Corrige el desplazamiento que provoca
:targetcuando hay una cabecera pegajosa. - Escribe el estilo de reserva de un componente web y comprueba que desaparece al registrarlo.
- Demuestra que
.x:emptydepende del formato del HTML y sustitúyelo por la versión robusta. - Coge tres estados de un proyecto tuyo y colócalos en el árbol de decisión. Justifica cada uno.