Módulo 14: CSS moderno
Objetivos del módulo
- Escribir CSS anidado de forma nativa, sin preprocesadores
- Simplificar selectores con
:is()y:where()y entender su especificidad - Usar
:has()como “selector padre” en formularios, tarjetas y layouts - Organizar la cascada con capas (
@layer) para convivir con resets y frameworks - Dominar
clamp(),min()ymax()en casos reales - Pensar en propiedades lógicas (
margin-inline,padding-block,inset,inline-size) - Mezclar y derivar colores con
color-mix()y la sintaxis de colores relativos - Aplicar mejoras progresivas con
@supports,@property,:focus-visible,text-wrap,@scopey las view transitions - Saber consultar la compatibilidad real en caniuse.com y con el sello Baseline
1. ¿Qué es el “CSS moderno”?
Durante años, muchas cosas que hoy parecen básicas (anidar reglas, crear variables, mezclar colores, seleccionar un elemento según su contenido) solo se podían hacer con preprocesadores como Sass o con JavaScript. Entre 2022 y 2025 los navegadores incorporaron a gran velocidad un conjunto de funcionalidades que cambiaron la forma de escribir CSS.
📘 Concepto: Llamamos CSS moderno al conjunto de características que ya funcionan en todos los navegadores actuales (Chrome, Edge, Firefox y Safari) y que sustituyen trucos, hacks o herramientas externas por soluciones nativas del propio lenguaje.
Piensa en ello como en un teléfono móvil: hace años necesitabas una cámara, un GPS y un reproductor de música por separado. Hoy vienen integrados. Con CSS ha pasado lo mismo con muchas funciones que antes requerían Sass o JavaScript.
| Antes necesitabas… | Ahora tienes en CSS nativo |
|---|---|
| Sass para anidar reglas | Anidamiento nativo con & |
| JavaScript para estilar un padre según su hijo | :has() |
| Selectores larguísimos repetidos | :is() y :where() |
Pelear con !important frente a frameworks | Capas de cascada @layer |
| Media queries para cada tamaño de fuente | clamp() |
Funciones lighten() / darken() de Sass | color-mix() y colores relativos |
| Librerías JS para animar cambios de página | View Transitions |
⚠️ Importante: Algunas funcionalidades de este módulo son muy recientes. Al final verás una tabla de compatibilidad orientativa y cómo comprobar tú mismo el soporte actualizado. La regla de oro es la mejora progresiva: la página debe funcionar sin la novedad y verse mejor con ella.
2. Anidamiento nativo (CSS Nesting)
2.1 El problema
Cuando estilas un componente, repites una y otra vez el selector padre:
.tarjeta { padding: 1rem; border: 1px solid #ddd; }
.tarjeta h2 { font-size: 1.25rem; }
.tarjeta p { color: #555; }
.tarjeta a { color: royalblue; }
.tarjeta a:hover { text-decoration: underline; }
2.2 La solución: reglas anidadas
Con el anidamiento nativo puedes escribir las reglas hijas dentro de la regla padre, igual que en Sass:
.tarjeta {
padding: 1rem;
border: 1px solid #ddd;
h2 {
font-size: 1.25rem; /* equivale a .tarjeta h2 */
}
p {
color: #555; /* equivale a .tarjeta p */
}
a {
color: royalblue; /* .tarjeta a */
&:hover {
text-decoration: underline; /* .tarjeta a:hover */
}
}
}
📘 Concepto: El símbolo
&representa el selector de la regla padre. Es obligatorio cuando quieres “pegar” algo al selector padre (una pseudoclase, una clase adicional), porque sin&el navegador interpreta un descendiente.
2.3 Usos del &
.boton {
background: #0d6efd;
color: white;
/* Pseudoclases y pseudoelementos: .boton:hover, .boton::after */
&:hover { background: #0b5ed7; }
&::after { content: " →"; }
/* Clase adicional en el MISMO elemento: .boton.boton--grande */
&.boton--grande { font-size: 1.25rem; }
/* Contexto padre: .modo-oscuro .boton */
.modo-oscuro & { background: #6ea8fe; color: #000; }
/* Hermano adyacente: .boton + .boton */
& + & { margin-inline-start: 0.5rem; }
}
Escribes (dentro de .boton) | Resultado |
|---|---|
&:hover | .boton:hover |
&.activo | .boton.activo |
& .icono o .icono | .boton .icono (descendiente) |
.oscuro & | .oscuro .boton |
& > span o > span | .boton > span |
& + & | .boton + .boton |
⚠️ Importante: A diferencia de Sass,
&__titulono concatena texto para formar.tarjeta__titulo(patrón BEM). En CSS nativo&es un selector completo, no una cadena. Si usas BEM, escribe la clase entera:.tarjeta__titulo { ... }.
2.4 Media queries anidadas
También puedes anidar @media, @supports o @container dentro de una regla. Así toda la información de un componente queda junta:
.cabecera {
display: flex;
flex-direction: column;
gap: 1rem;
@media (min-width: 768px) {
flex-direction: row; /* .cabecera en pantallas ≥ 768px */
justify-content: space-between;
}
}
2.5 Buenas prácticas
💡 Consejo: No anides más de 2 o 3 niveles. Un anidamiento profundo genera selectores muy específicos y difíciles de sobrescribir (
.a .b .c .d a). Anida para agrupar estados (&:hover,&:focus-visible) y media queries, no para replicar la estructura completa del HTML.
💡 Consejo: Las reglas anidadas se envuelven internamente en
:is(), así que la especificidad del padre se calcula como la del selector más específico de la lista. Lo verás en la sección siguiente.
✏️ Ejercicio: Toma el CSS de un menú de navegación que hayas hecho en módulos anteriores y reescríbelo usando anidamiento: estados
&:hovery&[aria-current="page"], y una media query anidada.
3. :is() y :where()
3.1 Agrupar selectores sin repetir
Imagina que quieres dar el mismo color a los enlaces dentro del encabezado, el pie y el menú lateral:
/* Antes */
header a:hover,
footer a:hover,
aside a:hover {
color: tomato;
}
/* Con :is() */
:is(header, footer, aside) a:hover {
color: tomato;
}
:is() recibe una lista de selectores y coincide con cualquier elemento que encaje con alguno de ellos. Otro ejemplo típico, para dar estilo a todos los títulos dentro de un artículo:
article :is(h1, h2, h3, h4) {
font-family: Georgia, serif;
line-height: 1.2;
margin-block: 1.5em 0.5em;
}
💡 Consejo:
:is()es tolerante a errores: si uno de los selectores de la lista no es válido, el resto sigue funcionando. Con una lista separada por comas normal, un solo selector inválido anula toda la regla.
3.2 :where(): lo mismo, pero con especificidad cero
:where() funciona exactamente igual que :is(), con una diferencia crucial en la especificidad:
| Pseudoclase | Especificidad |
|---|---|
:is(a, b, c) | La del selector más específico de la lista |
:where(a, b, c) | Siempre 0 (0,0,0) |
/* Especificidad (1,0,1): el #id de la lista "manda" */
:is(#principal, .contenido) p { color: navy; }
/* Especificidad (0,0,1): solo cuenta la "p" */
:where(#principal, .contenido) p { color: navy; }
3.3 ¿Para qué sirve una especificidad cero?
Para escribir estilos por defecto fáciles de sobrescribir. Es lo que hacen los resets modernos y las librerías bien diseñadas:
/* Reset: estilos base que cualquier clase podrá sobrescribir sin esfuerzo */
:where(ul, ol)[role="list"] {
list-style: none;
padding: 0;
}
/* Esta regla gana aunque sea una simple clase, porque :where() vale 0 */
.menu {
padding: 1rem;
}
📘 Concepto: Regla práctica: usa
:is()para escribir menos en tu CSS de componentes, y:where()para estilos base (resets, librerías, temas) que deben ceder ante cualquier otra regla.
4. :has(): el selector padre
4.1 La pieza que faltaba
Durante más de 20 años, CSS solo podía mirar “hacia abajo”: seleccionar hijos o descendientes de un elemento. No había forma de decir “estiliza esta tarjeta si contiene una imagen”. :has() lo resuelve.
📘 Concepto:
A:has(B)selecciona los elementos A que contienen (o tienen relacionado) algo que coincide con B. Es como preguntar: “¿este elemento tiene…?”.
/* Tarjetas que contienen una imagen */
.tarjeta:has(img) { padding-top: 0; }
/* Un <a> que contiene un <svg> directamente */
a:has(> svg) { display: inline-flex; gap: 0.25rem; }
/* Un h2 que va seguido inmediatamente de un párrafo con clase .subtitulo */
h2:has(+ p.subtitulo) { margin-bottom: 0; }
/* Una sección SIN encabezado */
section:not(:has(h2, h3)) { border-left: 4px solid orange; }
4.2 Ejemplo práctico: formularios
Uno de los usos más útiles es reaccionar al estado de los campos sin escribir JavaScript.
💻 Ejemplo: Formulario que resalta el grupo con error y se colorea al aceptar las condiciones.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>:has() en formularios</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 30rem; margin: 2rem auto; }
.campo {
display: grid;
gap: 0.25rem;
padding: 0.75rem;
border: 2px solid transparent;
border-radius: 0.5rem;
margin-block-end: 1rem;
/* El grupo cuyo input es inválido (tras interactuar) se marca en rojo */
&:has(input:user-invalid) {
border-color: crimson;
background: #fff0f0;
}
/* El grupo con el foco dentro se resalta */
&:has(:focus-visible) {
border-color: royalblue;
}
/* Mensaje de ayuda visible solo cuando hay error */
.error { display: none; color: crimson; font-size: 0.875rem; }
&:has(input:user-invalid) .error { display: block; }
}
/* El botón cambia de aspecto si las condiciones NO están aceptadas */
form:has(#condiciones:not(:checked)) button {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<form>
<div class="campo">
<label for="email">Correo electrónico</label>
<input type="email" id="email" required>
<span class="error">Introduce un correo válido.</span>
</div>
<div class="campo">
<label>
<input type="checkbox" id="condiciones"> Acepto las condiciones
</label>
</div>
<button type="submit">Enviar</button>
</form>
</body>
</html>
💡 Consejo:
:user-invalides como:invalid, pero solo se activa después de que el usuario haya interactuado con el campo. Así evitas mostrar errores en rojo nada más cargar la página.
4.3 Ejemplo práctico: tarjetas y layouts
/* Rejilla de tarjetas */
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 1.5rem;
}
.tarjeta {
border: 1px solid #e5e5e5;
border-radius: 0.75rem;
overflow: hidden;
/* Si la tarjeta tiene imagen, la ponemos en horizontal en pantallas grandes */
@media (min-width: 900px) {
&:has(img) {
display: grid;
grid-template-columns: 40% 1fr;
}
}
/* Tarjeta destacada: cuando contiene una etiqueta .badge-oferta */
&:has(.badge-oferta) {
border-color: gold;
box-shadow: 0 0 0 3px #ffe58a;
}
}
/* Efecto "foco": al pasar sobre una tarjeta, las demás se atenúan */
.rejilla:has(.tarjeta:hover) .tarjeta:not(:hover) {
opacity: 0.6;
transition: opacity 0.2s;
}
Un truco muy útil a nivel de página: body:has(dialog[open]) { overflow: hidden; } bloquea el scroll del fondo mientras haya un <dialog> abierto.
⚠️ Importante:
:has()es potente pero puede ser costoso si se usa en selectores muy amplios como*:has(...)obody:has(.algo .muy .profundo). Ánclalo a un contenedor concreto siempre que puedas.
✏️ Ejercicio: Crea una lista de tareas con checkboxes. Usando solo CSS y
:has(), tacha el texto de la tarea cuando su checkbox esté marcado (li:has(input:checked)).
5. Capas de cascada: @layer
5.1 El problema de la especificidad
Imagina que usas un framework con este selector:
/* Dentro del framework */
.nav .nav-item .nav-link.active { color: blue; }
Si quieres cambiar el color, tienes que escribir un selector aún más específico o recurrir a !important. Es una guerra que nadie gana.
5.2 La solución: capas
📘 Concepto: Las capas de cascada (
@layer) permiten agrupar reglas en niveles con nombre. Entre capas distintas, gana la capa declarada más tarde, sin importar la especificidad de los selectores. La especificidad solo se compara dentro de una misma capa.
Piensa en capas de pintura sobre una pared: la última capa que das es la que se ve, da igual lo gruesa que fuera la de debajo.
/* 1. Declaramos el orden de las capas (de menor a mayor prioridad) */
@layer reset, framework, componentes, utilidades;
/* 2. Rellenamos cada capa (el orden de escritura aquí da igual) */
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
}
@layer framework {
.nav .nav-item .nav-link.active { color: blue; } /* muy específico */
}
@layer componentes {
.enlace-activo { color: crimson; } /* poco específico... pero GANA */
}
@layer utilidades {
.texto-verde { color: green; } /* gana a todo lo anterior */
}
flowchart BT
A["Estilos del navegador"] --> B["@layer reset"]
B --> C["@layer framework"]
C --> D["@layer componentes"]
D --> E["@layer utilidades"]
E --> F["CSS sin capa (máxima prioridad)"]
5.3 Reglas clave
| Situación | Quién gana |
|---|---|
| Dos reglas en capas distintas | La de la capa declarada más tarde |
| Dos reglas en la misma capa | La de mayor especificidad (y si empatan, la última) |
| Regla en capa vs. regla sin capa | La regla sin capa |
!important entre capas | Se invierte: gana la capa declarada antes |
⚠️ Importante: Los estilos que no están en ninguna capa tienen más prioridad que cualquier capa. Si empiezas a usar capas, pon todo tu CSS en capas o tendrás sorpresas.
5.4 Meter un framework entero en una capa
Puedes importar una hoja externa directamente dentro de una capa. Así un framework como Bootstrap (que verás en los próximos módulos) queda “por debajo” de tu código:
/* estilos.css */
@layer reset, bootstrap, tema;
@import url("https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css") layer(bootstrap);
@layer tema {
/* Esta regla simple gana a .btn-primary de Bootstrap sin !important */
.btn-primary {
background-color: #6f42c1;
border-color: #6f42c1;
}
}
⚠️ Importante: Las reglas
@importdeben ir al principio del fichero, antes de cualquier otra regla (salvo@charsety las declaraciones@layerde orden).
💡 Consejo: Una estructura de capas muy habitual es
@layer reset, base, layout, componentes, utilidades;. Las utilidades (clases de una sola propiedad, como.oculto) van al final porque deben ganar siempre.
6. clamp(), min() y max() en profundidad
Ya conociste estas funciones al hablar de tipografía fluida en el módulo de responsive. Ahora vamos a dominar sus usos avanzados.
| Función | Devuelve | Ejemplo | Lectura |
|---|---|---|---|
min(a, b) | El valor más pequeño | width: min(100%, 60rem) | “Como mucho 60rem” |
max(a, b) | El valor más grande | padding: max(1rem, 3vw) | “Como mínimo 1rem” |
clamp(min, ideal, max) | El ideal, limitado entre min y max | font-size: clamp(1rem, 2.5vw, 2rem) | “Fluido, pero con topes” |
6.1 Contenedor sin media queries
/* Ancho máximo de 70rem, con 1rem de margen lateral en móviles */
.contenedor {
width: min(100% - 2rem, 70rem);
margin-inline: auto;
}
💡 Consejo: Dentro de
min(),max()yclamp()puedes operar directamente (100% - 2rem) sin escribircalc(). Recuerda dejar espacios alrededor de+y-.
6.2 Escala tipográfica fluida
:root {
--fs-sm: clamp(0.875rem, 0.83rem + 0.2vw, 1rem);
--fs-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--fs-lg: clamp(1.25rem, 1.1rem + 0.75vw, 1.75rem);
--fs-xl: clamp(1.75rem, 1.3rem + 2vw, 3rem);
}
body { font-size: var(--fs-base); }
h1 { font-size: var(--fs-xl); }
h2 { font-size: var(--fs-lg); }
⚠️ Importante: No uses solo
vwen el valor ideal (clamp(1rem, 3vw, 2rem)): si el usuario hace zoom,vwno crece y la accesibilidad empeora. Combina siempre una parte enremcon una parte envw(1rem + 1vw).
6.3 Espaciado y rejillas fluidas
.seccion {
/* Relleno vertical entre 2rem y 6rem según el ancho de pantalla */
padding-block: clamp(2rem, 8vw, 6rem);
}
.galeria {
display: grid;
/* Columnas de mínimo 15rem, pero nunca más anchas que el contenedor */
grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
gap: clamp(0.75rem, 2vw, 1.5rem);
}
El truco min(15rem, 100%) evita que, en pantallas más estrechas que 15rem, la columna desborde el contenedor.
7. Propiedades lógicas
7.1 Físico vs. lógico
Las propiedades tradicionales son físicas: left, right, top, bottom. Pero en árabe o hebreo el texto va de derecha a izquierda, y en japonés vertical puede ir de arriba abajo. Las propiedades lógicas se adaptan a la dirección de escritura.
📘 Concepto: CSS define dos ejes lógicos:
- Inline (en línea): la dirección en la que fluye el texto. En español, horizontal.
- Block (bloque): la dirección en la que se apilan los párrafos. En español, vertical.
Cada eje tiene un start (inicio) y un end (fin).
| Física (español, horizontal LTR) | Lógica |
|---|---|
margin-left | margin-inline-start |
margin-right | margin-inline-end |
margin-left + margin-right | margin-inline |
padding-top + padding-bottom | padding-block |
border-left | border-inline-start |
width | inline-size |
height | block-size |
max-width | max-inline-size |
top, right, bottom, left | inset (abreviada) |
text-align: left | text-align: start |
7.2 Ejemplos
.cita {
/* Borde al inicio del texto: izquierda en español, derecha en árabe */
border-inline-start: 4px solid steelblue;
padding-inline: 1.5rem 1rem; /* inicio 1.5rem, fin 1rem */
padding-block: 0.5rem; /* arriba y abajo */
margin-block-end: 2rem;
max-inline-size: 65ch; /* ancho máximo legible */
}
.overlay {
position: fixed;
inset: 0; /* equivale a top:0; right:0; bottom:0; left:0 */
background: rgb(0 0 0 / 0.5);
}
.etiqueta {
position: absolute;
inset-block-start: 0.5rem; /* top */
inset-inline-end: 0.5rem; /* right en LTR */
}
Prueba a añadir dir="rtl" al <html> y verás cómo todo se invierte automáticamente.
💡 Consejo: Aunque tu web solo esté en español,
margin-inline: auto,padding-blockeinset: 0son más cortos y expresivos que sus equivalentes físicos. Acostúmbrate a usarlos: Bootstrap 5 también usa este enfoque (por eso sus clases se llamanms-*yme-*, de start y end).
8. Color moderno: color-mix(), colores relativos y accent-color
8.1 color-mix()
color-mix() mezcla dos colores en un espacio de color y en la proporción que indiques, como un pintor en su paleta.
:root {
--primario: #0d6efd;
/* 80% primario + 20% blanco → versión más clara */
--primario-claro: color-mix(in srgb, var(--primario) 80%, white);
/* 70% primario + 30% negro → versión más oscura */
--primario-oscuro: color-mix(in srgb, var(--primario), black 30%);
/* Color semitransparente: mezclar con transparent */
--primario-suave: color-mix(in srgb, var(--primario) 15%, transparent);
}
.boton {
background: var(--primario);
&:hover { background: var(--primario-oscuro); }
}
.aviso {
background: var(--primario-suave);
border: 1px solid var(--primario-claro);
}
💡 Consejo: Mezclar en
oklchuoklab(color-mix(in oklch, ...)) suele dar resultados más naturales y uniformes quesrgb, porque estos espacios están diseñados según la percepción humana del color.
8.2 Colores relativos
La sintaxis de color relativo permite partir de un color existente y modificar sus componentes con la palabra clave from:
:root {
--marca: #e63946;
}
.ejemplos {
/* Mismo color con 50% de opacidad */
--marca-50: rgb(from var(--marca) r g b / 0.5);
/* Más claro: aumentamos la luminosidad (l) en OKLCH */
--marca-clara: oklch(from var(--marca) calc(l + 0.15) c h);
/* Complementario: giramos el tono 180 grados en HSL */
--marca-complementaria: hsl(from var(--marca) calc(h + 180) s l);
}
Las letras r g b, h s l o l c h representan los canales del color de origen, y puedes usarlas dentro de calc().
📘 Concepto: Con
color-mix()y los colores relativos puedes generar toda una paleta a partir de un único color de marca, algo que antes solo podías hacer con Sass (lighten(),darken(),rgba()).
8.3 accent-color
accent-color cambia el color de los controles nativos (checkbox, radio, range, progress) con una sola línea, manteniendo su accesibilidad:
:root {
accent-color: #6f42c1;
}
<input type="checkbox" checked> Suscribirme
<input type="radio" name="plan" checked> Plan básico
<input type="range" min="0" max="100" value="60">
<progress value="70" max="100"></progress>
💡 Consejo: El navegador ajusta automáticamente el color de la marca de verificación (blanca o negra) para que contraste con tu
accent-color.
9. Detección de características: @supports
@supports aplica estilos solo si el navegador entiende una propiedad, un valor o un selector. Es la herramienta básica de la mejora progresiva.
/* Estilo base que funciona en todas partes */
.tarjeta { background: #f1f5ff; }
/* Si el navegador soporta color-mix(), lo usamos */
@supports (background: color-mix(in srgb, red, blue)) {
.tarjeta {
background: color-mix(in oklch, var(--primario) 10%, white);
}
}
/* Detectar soporte de un selector */
@supports selector(:has(a)) {
.menu:has(.submenu) > a::after { content: " ▾"; }
}
⚠️ Importante:
@supportscomprueba si el navegador entiende la sintaxis, no si la implementación es perfecta. Úsalo para ofrecer alternativas, no como garantía absoluta.
10. @property: custom properties tipadas
10.1 El límite de las variables normales
Las variables CSS (--color) son, para el navegador, simples trozos de texto. Por eso no se pueden animar: el navegador no sabe que --angulo: 0deg es un ángulo que puede interpolar hasta 360deg.
10.2 Registrar una propiedad
📘 Concepto:
@propertyregistra una custom property indicando su tipo (syntax), si se hereda (inherits) y su valor inicial (initial-value). Con el tipo conocido, el navegador puede validarla y animarla.
@property --angulo {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.borde-animado {
border: 4px solid transparent;
border-radius: 1rem;
padding: 2rem;
/* Degradado cónico que usa la variable */
background:
linear-gradient(white, white) padding-box,
conic-gradient(from var(--angulo), #ff006e, #3a86ff, #8338ec, #ff006e) border-box;
animation: girar 4s linear infinite;
}
@keyframes girar {
to { --angulo: 360deg; } /* ¡ahora sí se interpola! */
}
@media (prefers-reduced-motion: reduce) {
.borde-animado { animation: none; }
}
10.3 Tipos habituales
syntax | Ejemplo de valor |
|---|---|
"<length>" | 10px, 2rem |
"<percentage>" | 50% |
"<length-percentage>" | 2rem o 50% |
"<number>" | 1.5 |
"<integer>" | 3 |
"<color>" | #ff0000, oklch(70% 0.1 200) |
"<angle>" | 45deg, 0.5turn |
"<time>" | 300ms |
"*" | Cualquier valor (como una variable normal) |
💻 Ejemplo: Animar un degradado de fondo al pasar el ratón, algo imposible con variables normales:
@property --stop {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.boton-degradado {
background: linear-gradient(90deg, #0d6efd var(--stop), #6f42c1);
color: white;
transition: --stop 0.4s ease;
&:hover { --stop: 60%; }
}
11. Accesibilidad y tipografía: :focus-visible y text-wrap
11.1 :focus-visible
Un error muy común es quitar el contorno de foco (outline: none) porque “queda feo” al hacer clic. Eso deja sin referencia a quien navega con teclado.
📘 Concepto:
:focus-visiblesolo se activa cuando el navegador considera que el foco debe mostrarse: típicamente al navegar con el teclado (Tab), pero no al hacer clic con el ratón en un botón.
/* Quitamos el outline por defecto SOLO cuando no debe ser visible... */
:focus:not(:focus-visible) {
outline: none;
}
/* ...y diseñamos un foco claro y bonito para el teclado */
:focus-visible {
outline: 3px solid #ff9f1c;
outline-offset: 3px;
border-radius: 4px;
}
⚠️ Importante: Nunca escribas
*:focus { outline: none; }sin ofrecer una alternativa visible. Es uno de los fallos de accesibilidad más frecuentes y lo detectan las auditorías como Lighthouse.
11.2 text-wrap: balance y text-wrap: pretty
/* Títulos: reparte el texto en líneas de longitud similar */
h1, h2, h3 {
text-wrap: balance;
}
/* Párrafos: evita que la última línea quede con una sola palabra (viuda) */
p {
text-wrap: pretty;
}
| Valor | Para qué | Notas |
|---|---|---|
balance | Títulos y textos cortos | Solo se aplica a bloques de pocas líneas, por rendimiento |
pretty | Párrafos largos | Mejora el final de los párrafos; el algoritmo varía según el navegador |
Sin balance, un título largo puede quedar con una única palabra huérfana en la segunda línea (“…con ejemplos prácticos y / reales”); con balance, las dos líneas tienen una longitud parecida.
💡 Consejo: Son mejoras progresivas perfectas: si un navegador no las soporta, el texto simplemente se reparte como siempre. Puedes añadirlas a cualquier proyecto sin riesgo.
12. @scope: estilos con ámbito
12.1 Qué resuelve
@scope limita unos estilos a un fragmento del DOM, con un inicio (raíz del ámbito) y, opcionalmente, un límite inferior donde dejan de aplicarse.
/* Los estilos solo afectan dentro de .tarjeta... */
@scope (.tarjeta) {
img { border-radius: 0.5rem; }
h2 { color: darkslateblue; }
}
/* ...y aquí, además, se detienen al llegar a .tarjeta__contenido */
@scope (.tarjeta) to (.tarjeta__contenido) {
img { aspect-ratio: 16 / 9; object-fit: cover; }
}
Este segundo patrón se llama “donut scope” (ámbito en forma de rosquilla): estilas el “borde” de un componente sin afectar al contenido que se inserta dentro (por ejemplo, el HTML que escribe un usuario en un CMS).
Además, @scope introduce la proximidad en la cascada: si dos ámbitos compiten con la misma especificidad (por ejemplo @scope (.tema-claro) y @scope (.tema-oscuro) anidados), gana el ámbito cuya raíz está más cerca del elemento en el árbol DOM.
⚠️ Importante (soporte):
@scopellegó a Chrome/Edge en 2023 y a Safari en 2024, pero Firefox no lo incorporó hasta la versión 146 (diciembre de 2025). Por eso tiene el sello Baseline “recientemente disponible”: funciona en las versiones actuales, pero usuarios con navegadores algo antiguos no lo tendrán. Úsalo con precaución o solo para mejoras no críticas.
13. Introducción a las View Transitions
Las View Transitions animan el paso de un estado visual a otro: el navegador toma una “foto” del estado anterior, aplica el cambio y hace un fundido (o la animación que definas) entre ambas.
13.1 Entre páginas (sitios de varias páginas)
Con una sola regla CSS en todas las páginas de tu sitio (del mismo origen), la navegación entre ellas tendrá un fundido suave:
@view-transition {
navigation: auto;
}
13.2 Transiciones con nombre
Puedes dar un nombre a un elemento para que el navegador lo “mueva” de una página a otra (por ejemplo, la miniatura de un producto que crece hasta la foto grande de la página de detalle):
/* En el listado */
.producto-miniatura { view-transition-name: foto-producto; }
/* En la página de detalle */
.producto-foto-grande { view-transition-name: foto-producto; }
/* Personalizar la duración de la animación por defecto */
::view-transition-group(foto-producto) {
animation-duration: 0.5s;
}
/* Respetar a quien prefiere menos movimiento */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
⚠️ Importante: Cada
view-transition-namedebe ser único en la página en un momento dado.
13.3 Dentro de la misma página (con JavaScript)
// Cambiar el tema con una transición suave, si el navegador lo soporta
function cambiarTema() {
const aplicar = () => document.documentElement.classList.toggle('oscuro');
if (!document.startViewTransition) {
aplicar(); // Navegadores sin soporte: cambio directo
return;
}
document.startViewTransition(aplicar);
}
💡 Consejo (soporte): Las transiciones dentro de la misma página (
startViewTransition) ya funcionan en los navegadores principales. Las transiciones entre páginas (@view-transition) funcionan en Chrome, Edge y Safari, y en Firefox estaban en desarrollo en el momento de escribir este módulo. Como en el peor caso la navegación simplemente no se anima, puedes usarlas sin miedo.
14. Compatibilidad: caniuse.com y Baseline
14.1 Tabla orientativa
Esta tabla resume el estado aproximado a finales de 2026. Compruébalo siempre en las fuentes oficiales, porque cambia cada pocos meses.
| Característica | Estado aproximado | ¿Usar en producción? |
|---|---|---|
| Anidamiento nativo | Baseline (2023) | Sí |
:is() / :where() | Baseline (2021) | Sí |
:has() | Baseline (2023) | Sí |
@layer | Baseline (2022) | Sí |
clamp(), min(), max() | Baseline (2020) | Sí |
| Propiedades lógicas | Baseline (ampliamente disponible) | Sí |
color-mix() | Baseline (2023) | Sí |
Colores relativos (from) | Baseline (2024) | Sí, con alternativa para navegadores antiguos |
accent-color | Baseline (2021) | Sí |
@supports | Baseline (ampliamente disponible) | Sí |
@property | Baseline (2024) | Sí, como mejora progresiva |
:focus-visible | Baseline (2022) | Sí |
text-wrap: balance | Baseline (2024) | Sí (mejora progresiva) |
text-wrap: pretty | Parcial (no en todos los navegadores) | Sí (mejora progresiva) |
@scope | Baseline recientemente disponible (2025) | Con precaución |
| View Transitions (misma página) | Baseline recientemente disponible (2025) | Sí, como mejora progresiva |
| View Transitions (entre páginas) | Parcial | Sí, como mejora progresiva |
14.2 ¿Qué es Baseline?
📘 Concepto: Baseline es una iniciativa conjunta de los fabricantes de navegadores (a través del grupo WebDX del W3C) que indica si una característica funciona en las últimas versiones de Chrome, Edge, Firefox y Safari, tanto en escritorio como en móvil.
| Sello | Significado |
|---|---|
| Baseline ampliamente disponible (widely available) | Lleva al menos 30 meses funcionando en todos los navegadores principales. Úsala sin preocuparte |
| Baseline recientemente disponible (newly available) | Ya funciona en las versiones actuales de todos, pero hace poco. Usuarios con navegadores desactualizados pueden no tenerla |
| Disponibilidad limitada | Aún no funciona en todos los navegadores principales |
Verás el sello Baseline en la parte superior de cada página de referencia de MDN Web Docs (developer.mozilla.org) y en web.dev.
14.3 Cómo usar caniuse.com
- Entra en caniuse.com.
- Escribe la característica en el buscador, por ejemplo
hasocss nesting. - Observa la tabla: verde = soportado, amarillo = parcial (lee las notas), rojo = no soportado.
- Fíjate en el porcentaje de “Global usage”: indica qué porcentaje de usuarios reales tiene un navegador compatible.
- Revisa las pestañas “Notes” y “Known issues” para detalles importantes.
💡 Consejo: Si tu web tiene un público concreto (por ejemplo, una intranet con un navegador corporativo antiguo), consulta las estadísticas reales de tus visitantes en tu herramienta de analítica. El porcentaje global es solo orientativo.
15. Errores comunes
| Error | Por qué ocurre | Solución |
|---|---|---|
&__titulo no funciona en CSS nativo | & no concatena texto como en Sass | Escribe la clase completa .tarjeta__titulo |
| Mi CSS en capas pierde contra estilos “normales” | El CSS sin capa tiene más prioridad | Mete todo tu CSS en capas o deja fuera solo lo que deba ganar |
:is(#id, .clase) es demasiado específico | :is() toma la especificidad más alta | Usa :where() si no quieres sumar especificidad |
clamp() con solo vw no responde al zoom | vw depende del viewport, no de la fuente | Combina rem + vw en el valor ideal |
min(100%-2rem, 60rem) no funciona | Faltan espacios alrededor del operador | min(100% - 2rem, 60rem) |
| La variable no se anima | El navegador no conoce su tipo | Regístrala con @property |
| El foco desaparece al navegar con Tab | Se usó outline: none en :focus | Usa :focus-visible con un contorno visible |
16. Ejercicios
Ejercicio 1: Refactorizar con anidamiento
Escribe el CSS de un .menu flexible (display: flex; gap: 1rem) cuyos enlaces sean grises sin subrayado, azules en :hover, en negrita con la clase .activo, y que pase a columna por debajo de 600px. Todo debe quedar dentro de un único bloque .menu { ... } usando & y una media query anidada.
Ejercicio 2: Reset con :where()
Escribe un pequeño reset moderno (box-sizing, márgenes del body, imágenes responsive, font: inherit en controles de formulario) usando :where() en todos los selectores. Después comprueba en DevTools que una simple clase .boton { font-size: 2rem; } sobrescribe tu reset sin problemas.
Ejercicio 3: Formulario inteligente con :has()
Crea un formulario de registro con nombre, email, contraseña y un checkbox “Quiero recibir el boletín”. Usando solo CSS:
- Resalta en rojo el grupo de un campo con
:user-invalid. - Si el checkbox del boletín está marcado, muestra un campo adicional “Frecuencia” (oculto por defecto).
- Atenúa el botón de envío mientras el formulario contenga algún campo inválido (
form:has(:invalid)).
Ejercicio 4: Ordenar la cascada con @layer
Crea un fichero CSS con las capas reset, base, componentes, utilidades. Dentro de componentes, escribe un selector muy específico (.pagina .contenido .tarjeta p) que ponga el texto en gris. En utilidades, crea .texto-rojo { color: red; }. Comprueba que la utilidad gana. Después, mueve la utilidad fuera de las capas y explica qué ocurre si la regla de componentes también queda fuera.
Ejercicio 5: Paleta a partir de un color
Define --marca: #2a9d8f; en :root y genera, con color-mix() y colores relativos, cinco variantes: muy clara (fondo), clara (borde), base, oscura (hover) y semitransparente al 20%. Crea una tarjeta y un botón que usen esas variantes. Cambia solo --marca y comprueba que todo el diseño se adapta.
Ejercicio 6: Botón con borde giratorio
Usando @property, conic-gradient() y @keyframes, crea un botón cuyo borde sea un degradado que gire continuamente. Respeta prefers-reduced-motion y añade un :focus-visible bien visible.
Ejercicio 7: Página tipográfica moderna
Maqueta un artículo de blog con: contenedor width: min(100% - 2rem, 65ch), escala tipográfica con clamp(), títulos con text-wrap: balance, párrafos con text-wrap: pretty, citas con border-inline-start y padding-inline, y accent-color en un formulario de suscripción al final. Añade dir="rtl" temporalmente para comprobar que las propiedades lógicas funcionan.
Ejercicio 8: Investigar compatibilidad
Elige tres características de este módulo y, para cada una, busca en MDN su estado Baseline y en caniuse.com su porcentaje de uso global. Redacta una breve recomendación (¿la usarías hoy en la web de un cliente?, ¿con qué alternativa?).
Resumen
| Concepto | Sintaxis | Para qué |
|---|---|---|
| Anidamiento | .a { &:hover {} .b {} } | Agrupar estilos de un componente |
:is() | :is(h1, h2) a | Agrupar selectores (especificidad del más alto) |
:where() | :where(ul, ol) | Agrupar con especificidad 0 |
:has() | .tarjeta:has(img) | Seleccionar según contenido (selector padre) |
| Capas | @layer reset, base, comp; | Controlar la prioridad sin especificidad |
| Importar en capa | @import url(x.css) layer(nombre); | Meter un framework en una capa |
clamp() | clamp(1rem, 1rem + 1vw, 2rem) | Valores fluidos con límites |
min() / max() | width: min(100% - 2rem, 70rem) | Límites sin media queries |
| Propiedades lógicas | margin-inline, padding-block, inset | Diseño independiente de la dirección |
color-mix() | color-mix(in oklch, red 70%, white) | Mezclar colores |
| Color relativo | rgb(from var(--c) r g b / 0.5) | Derivar colores de otro |
accent-color | accent-color: purple; | Color de controles nativos |
@supports | @supports (prop: valor) {} | Mejora progresiva |
@property | @property --x { syntax: "<angle>"; ... } | Variables tipadas y animables |
:focus-visible | :focus-visible { outline: ... } | Foco accesible para teclado |
text-wrap | balance / pretty | Mejor reparto de líneas |
@scope | @scope (.a) to (.b) {} | Estilos limitados a un fragmento del DOM |
| View Transitions | @view-transition { navigation: auto; } | Animar cambios de vista o de página |
| Baseline | MDN, web.dev, caniuse.com | Saber qué se puede usar hoy |