CSS moderno en 2026: Las funcionalidades que cambiaron cómo escribo estilos
Container queries, CSS layers, :has(), color-mix(), nesting, animaciones basadas en scroll y anchor positioning. Las funcionalidades de CSS que me hicieron dejar de recurrir a JavaScript.
Borré mi último archivo Sass hace seis meses. No porque estuviera haciendo una declaración. Porque genuinamente ya no lo necesitaba.
Durante más de una década, CSS fue el lenguaje por el que nos disculpábamos. Necesitábamos preprocesadores para nesting y variables. Necesitábamos JavaScript para dimensionamiento basado en contenedores, animaciones vinculadas al scroll, selección de padres, y la mitad de los patrones de layout que los diseñadores nos entregaban. Construimos sistemas enteros en runtime — bibliotecas CSS-in-JS, frameworks de utilidades, cadenas de plugins de PostCSS — para compensar lo que el lenguaje no podía hacer de forma nativa.
Esa era terminó. No "casi terminó." No "llegando ahí." Terminó.
Las funcionalidades que llegaron a los navegadores entre 2024 y 2026 no solo añadieron comodidad. Cambiaron el modelo mental. CSS ya no es un lenguaje de estilos contra el que luchas. Es un lenguaje de estilos que realmente piensa en componentes, gestión de especificidad, diseño responsive a nivel de elemento, y animación sin runtime.
Esto es lo que cambió, por qué importa, y qué puedes dejar de hacer gracias a ello.
Container Queries: El fin de "¿Cuánto mide el viewport?"#
Esta es la que alteró fundamentalmente cómo pienso sobre diseño responsive. Durante veinte años, tuvimos media queries. Las media queries preguntan: "¿cuánto mide el viewport?" Las container queries preguntan: "¿cuánto mide el contenedor en el que vive este componente?"
Esa distinción suena sutil. No lo es. Es la diferencia entre componentes que solo funcionan en un contexto de layout y componentes que funcionan en todas partes.
El problema que existió durante dos décadas#
Considera un componente de tarjeta. En un sidebar, debería apilarse verticalmente con una imagen pequeña. En un área de contenido principal, debería ir horizontal con una imagen más grande. En una sección hero a ancho completo, debería ser algo completamente diferente.
Con media queries, escribirías algo así:
/* La forma antigua: acoplando estilos del componente al layout de la página */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sidebar .card {
/* Sigue vertical en el sidebar */
}
.main-content .card {
flex-direction: row;
/* Horizontal en el área principal */
}
}
@media (min-width: 1200px) {
.hero .card {
/* Otro layout más */
}
}La tarjeta conoce .sidebar, .main-content, y .hero. Conoce la página. Ya no es un componente — es un fragmento consciente de la página. Muévelo a una página diferente y todo se rompe.
Las container queries arreglan esto completamente#
/* La forma con container query: el componente solo sabe de sí mismo */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
font-size: 1.125rem;
}
}La tarjeta no sabe dónde vive. No le importa. Ponla en un sidebar de 300px y es vertical. Ponla en un área principal de 700px y es horizontal. Déjala caer en una sección a ancho completo y se adapta. Cero conocimiento del layout de la página requerido.
inline-size vs size#
Casi siempre querrás container-type: inline-size. Esto habilita queries en el eje inline (ancho en modos de escritura horizontales). Usar container-type: size habilita queries en ambos ejes inline y block, pero requiere que el contenedor tenga dimensionamiento explícito en ambas dimensiones, lo cual rompe el flujo normal del documento en la mayoría de los casos.
/* Esto es lo que quieres el 99% del tiempo */
.wrapper {
container-type: inline-size;
}
/* Esto requiere altura explícita — rara vez lo que quieres */
.wrapper-both {
container-type: size;
height: 500px; /* requerido, o colapsa */
}Contenedores con nombre para contextos anidados#
Cuando anidas contenedores, nombrarlos se vuelve esencial:
.page-layout {
container-type: inline-size;
container-name: layout;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* Apuntar al sidebar específicamente, no al ancestro más cercano */
@container sidebar (max-width: 250px) {
.nav-item {
font-size: 0.875rem;
padding: 0.25rem;
}
}
/* Apuntar al layout de la página */
@container layout (min-width: 1200px) {
.page-header {
font-size: 2.5rem;
}
}Sin nombres, @container consulta al ancestro más cercano con containment. Con anidamiento, ese a menudo no es el contenedor que quieres. Nómbralos. Siempre.
Unidades de container query#
Esta está infravalorada. Las unidades de container query (cqi, cqb, cqw, cqh) te permiten dimensionar cosas relativas al contenedor, no al viewport:
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqi es el 4% del tamaño inline del contenedor. El título escala con el contenedor, no con la ventana. Esto es lo que la tipografía fluida debería haber sido desde el principio.
CSS Layers: La guerra de especificidad terminó#
Si las container queries cambiaron cómo pienso sobre diseño responsive, @layer cambió cómo pienso sobre la arquitectura CSS. Por primera vez, tenemos una forma sana y declarativa de gestionar la especificidad a lo largo de un proyecto entero.
El problema#
La especificidad de CSS es un sistema de puntos que no le importan tus intenciones. Una clase de utilidad con .text-red pierde contra .card .title porque el último tiene mayor especificidad. La solución siempre fue la misma: hacer tus selectores más específicos, añadir !important, o reestructurar todo.
Construimos metodologías enteras (BEM, SMACSS, ITCSS) y cadenas de herramientas solo para evitar conflictos de especificidad. Todo eso era un workaround para una funcionalidad que faltaba en el lenguaje.
Ordenamiento de capas#
@layer te permite declarar el orden en que los grupos de estilos son considerados, independientemente de la especificidad dentro de esos grupos:
/* Declarar orden de capas — esta sola línea lo controla todo */
@layer reset, base, components, utilities, overrides;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer base {
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: oklch(20% 0 0);
}
h1, h2, h3 {
line-height: 1.2;
text-wrap: balance;
}
}
@layer components {
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
}
.card .title {
font-size: 1.25rem;
font-weight: 600;
color: oklch(25% 0.05 260);
}
}
@layer utilities {
.text-red {
color: oklch(55% 0.25 25);
}
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}Aunque .card .title tiene mayor especificidad que .text-red, la utilidad gana porque la capa utilities se declara después de components. Sin !important. Sin hacks de especificidad. El orden de las capas es la última palabra.
Cómo Tailwind CSS v4 usa layers#
Tailwind v4 se apoya fuertemente en @layer. Cuando escribes @import "tailwindcss", obtienes:
@layer theme, base, components, utilities;Cada utilidad de Tailwind vive en la capa utilities. Tus estilos de componentes personalizados van en components. Por eso una clase text-red-500 puede sobreescribir el color de un componente sin necesitar !important — está en una capa posterior.
Si estás construyendo tu propio design system sin Tailwind, copia esta arquitectura. Es la correcta:
@layer reset, tokens, base, layouts, components, utilities, overrides;Siete capas son suficientes. Nunca he necesitado más.
Los estilos sin capa ganan sobre todo#
Un gotcha: los estilos que no están en ninguna capa tienen la mayor prioridad. Esto es realmente útil — significa que tus overrides específicos de una página ganan automáticamente:
@layer components {
.modal {
background: white;
}
}
/* No está en ninguna capa — gana sobre todo lo que está en capas */
.special-page .modal {
background: oklch(95% 0.02 260);
}Pero también significa que CSS de terceros que no es consciente de las capas puede sobreescribir todo tu sistema. Envuelve los estilos de terceros en una capa para controlarlos:
@layer third-party {
@import url("some-library.css");
}El selector :has(): El selector padre que siempre quisimos#
Durante literalmente décadas, los desarrolladores pidieron un selector padre. "Quiero estilizar un padre basándome en sus hijos." La respuesta siempre fue "CSS no puede hacer eso" seguido de un workaround con JavaScript. :has() cambia esto completamente, y resulta que es aún más poderoso de lo que pedíamos.
Selección básica de padre#
/* Estilizar un form group cuando su input tiene focus */
.form-group:has(input:focus) {
border-color: oklch(55% 0.2 260);
box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
/* Estilizar una tarjeta diferente cuando contiene una imagen */
.card:has(img) {
padding-top: 0;
}
.card:has(img) .card-content {
padding: 1.5rem;
}
/* Una tarjeta sin imagen recibe tratamiento diferente */
.card:not(:has(img)) {
border-left: 4px solid oklch(55% 0.2 260);
}Estados de validación de formularios sin JavaScript#
Aquí es donde :has() se vuelve genuinamente emocionante. Combinado con pseudo-clases de validación HTML, puedes construir interfaces de formularios que responden al estado de validez con cero JavaScript:
/* El wrapper del campo reacciona a la validez de su input */
.field:has(input:invalid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.25 25);
}
.field:has(input:valid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.2 145);
}
.field {
--field-color: oklch(70% 0 0);
border: 2px solid var(--field-color);
border-radius: 0.5rem;
padding: 0.75rem;
transition: border-color 0.2s;
}
.field label {
color: var(--field-color);
font-size: 0.875rem;
font-weight: 500;
}
.field .error-message {
display: none;
color: oklch(55% 0.25 25);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}La parte :not(:placeholder-shown) es crucial — previene que los estilos de validación aparezcan en campos vacíos que aún no han sido tocados.
Layout basado en la cantidad de hijos#
Este patrón es absurdamente útil y era genuinamente imposible antes de :has():
/* Ajustar columnas del grid basándose en cuántos elementos existen */
.grid-auto:has(> :nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
.grid-auto:has(> :nth-child(7)) {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto:has(> :nth-child(13)) {
grid-template-columns: repeat(4, 1fr);
}El grid cambia su cantidad de columnas basándose en cuántos hijos tiene. Sin JavaScript. Sin ResizeObserver. Sin toggle de clases.
Detección de sidebar#
Uno de mis patrones favoritos — cambiar el layout basándose en si existe un sidebar:
.page-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
/* Si el layout contiene un sidebar, cambiar a dos columnas */
.page-layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Ajustar el ancho del contenido principal cuando el sidebar está presente */
.page-layout:has(.sidebar) .main-content {
max-width: 65ch;
}Añade un componente sidebar al DOM, el layout se ajusta. Elimínalo, se ajusta de vuelta. El CSS es la fuente de verdad, no una variable de estado.
Combinando :has() con otros selectores#
:has() se compone hermosamente con todo lo demás:
/* Estilizar un artículo solo cuando tiene una clase específica de figure */
article:has(figure.full-bleed) {
overflow: visible;
}
/* Una navegación que cambia cuando contiene un input de búsqueda */
nav:has(input[type="search"]:focus) {
background: oklch(98% 0 0);
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
/* Habilitar modo oscuro a nivel de componente */
.theme-switch:has(input:checked) ~ main {
color-scheme: dark;
background: oklch(15% 0 0);
color: oklch(90% 0 0);
}El soporte de navegadores es excelente ahora. Todos los navegadores modernos soportan :has() desde principios de 2024. No hay razón para no usarlo.
CSS Nesting: Nativo, finalmente estable#
No voy a sobrevender esto. El nesting de CSS es agradable. No es revolucionario como lo son las container queries o :has(). Pero elimina una de las últimas razones para usar Sass, y eso importa.
La sintaxis#
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.description {
color: oklch(40% 0 0);
line-height: 1.7;
}
&:hover {
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
}
&.featured {
border: 2px solid oklch(60% 0.2 260);
}
}Esto es CSS válido. Sin paso de build. Sin preprocesador. Sin plugin de PostCSS. El navegador lo maneja nativamente.
Diferencias con Sass#
Hay algunas diferencias de sintaxis que vale la pena conocer:
/* CSS Nesting — funciona ahora en todos los navegadores */
.parent {
/* El nesting directo de clases/elementos funciona sin & */
.child {
color: red;
}
/* & es requerido para pseudo-clases y selectores compuestos */
&:hover {
background: blue;
}
&.active {
font-weight: bold;
}
/* Media queries anidadas — esto es genial */
@media (width >= 768px) {
flex-direction: row;
}
}En las primeras implementaciones, necesitabas & antes de los selectores de elemento (como & p en lugar de solo p). Esa restricción se relajó. A partir de 2025, todos los navegadores principales soportan nesting directo de elementos: .parent { p { ... } } funciona bien.
Media queries anidadas#
Esta es la funcionalidad estrella del CSS nesting, en mi opinión. No el nesting de selectores — la capacidad de poner media queries dentro de un bloque de reglas:
.hero {
padding: 2rem;
font-size: 1rem;
@media (width >= 768px) {
padding: 4rem;
font-size: 1.25rem;
}
@media (width >= 1200px) {
padding: 6rem;
font-size: 1.5rem;
}
}Compara esto con la forma antigua donde tus estilos de .hero estaban dispersos en tres bloques @media diferentes, posiblemente a cientos de líneas de distancia. El nesting mantiene el comportamiento responsive co-localizado con el componente. La legibilidad mejora drásticamente.
No sobre-anides#
Una advertencia: solo porque puedes anidar seis niveles de profundidad no significa que debas. El mismo consejo de Sass aplica aquí. Si tu nesting crea selectores como .page .section .card .content .text .highlight, has creado un monstruo de especificidad y una pesadilla de mantenimiento. Dos o tres niveles es el punto ideal.
/* Bien — dos niveles */
.nav {
.link {
color: inherit;
&:hover {
color: oklch(55% 0.2 260);
}
}
}
/* Mal — pesadilla de especificidad */
.page {
.layout {
.sidebar {
.nav {
.list {
.item {
.link {
color: red; /* buena suerte sobreescribiendo esto */
}
}
}
}
}
}
}Funciones de color: oklch() y color-mix() lo cambian todo#
hsl() tuvo una buena racha. Era más intuitivo que rgb(). Pero tiene un defecto fundamental: no es perceptualmente uniforme. Un hsl(60, 100%, 50%) (amarillo) se ve dramáticamente más claro al ojo humano que hsl(240, 100%, 50%) (azul), aunque tienen el mismo valor de luminosidad.
Por qué oklch() gana#
oklch() es perceptualmente uniforme. Valores iguales de luminosidad se ven igualmente luminosos. Esto importa enormemente al generar paletas de colores, crear temas, y asegurar contraste accesible:
:root {
/* oklch(luminosidad croma tono) */
--color-primary: oklch(55% 0.2 260); /* azul */
--color-secondary: oklch(55% 0.2 330); /* púrpura */
--color-success: oklch(55% 0.2 145); /* verde */
--color-danger: oklch(55% 0.25 25); /* rojo */
--color-warning: oklch(75% 0.18 85); /* amarillo — L más alto para igual percepción */
/* Todos estos se ven igualmente "medios" al ojo humano */
/* Con HSL, necesitarías diferentes valores de luminosidad para cada tono */
}Los tres valores son intuitivos una vez que los entiendes:
- Luminosidad (0% a 100%): Qué tan claro u oscuro. 0% es negro, 100% es blanco.
- Croma (0 a ~0.37): Qué tan vívido. 0 es gris, más alto es más saturado.
- Tono (0 a 360): El ángulo de color. 0/360 es rojo rosado, 145 es verde, 260 es azul.
color-mix() para colores derivados#
color-mix() te permite crear colores a partir de otros colores en tiempo de ejecución. Sin función darken() de Sass. Sin JavaScript. Solo CSS:
:root {
--brand: oklch(55% 0.2 260);
/* Aclarar mezclando con blanco */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
/* Oscurecer mezclando con negro */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* Crear un fondo sutil */
--brand-bg: color-mix(in oklch, var(--brand) 8%, white);
/* Versión semi-transparente */
--brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
.button {
background: var(--brand);
color: white;
&:hover {
background: var(--brand-dark);
}
&:active {
background: color-mix(in oklch, var(--brand) 60%, black);
}
}
.button.secondary {
background: var(--brand-bg);
color: var(--brand-dark);
&:hover {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}La parte in oklch importa. Mezclar en srgb da colores intermedios embarrados. Mezclar en oklch da resultados perceptualmente uniformes. Siempre mezcla en oklch.
Construyendo una paleta completa con oklch()#
Así es como genero una paleta completa de tonalidades a partir de un solo tono:
:root {
--hue: 260;
--chroma: 0.2;
--color-50: oklch(97% calc(var(--chroma) * 0.1) var(--hue));
--color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
--color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
--color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
--color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
--color-500: oklch(55% var(--chroma) var(--hue));
--color-600: oklch(48% var(--chroma) var(--hue));
--color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
--color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
--color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
--color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}Cambia --hue a 145 y tienes una paleta verde. Cámbialo a 25 y tienes rojo. Los pasos de luminosidad son perceptualmente uniformes. El croma se atenúa en los extremos para que los tonos más claros y oscuros no estén sobresaturados. Este tipo de cosa solía requerir una herramienta de diseño o una función de Sass. Ahora son ocho líneas de CSS.
Animaciones basadas en scroll: Sin JavaScript necesario#
Esta es la funcionalidad que me hizo eliminar más JavaScript. Las animaciones vinculadas al scroll — barras de progreso, efectos parallax, animaciones de revelado, headers fijos con transiciones — solían requerir IntersectionObserver, listeners del evento scroll, o una biblioteca como GSAP. Ahora es CSS.
Indicador de progreso de scroll#
La clásica "barra de progreso de lectura" en la parte superior de una página de artículo:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: oklch(55% 0.2 260);
transform-origin: left;
z-index: 1000;
animation: grow-progress linear both;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}Eso es todo. El indicador completo de progreso de lectura. Sin JavaScript. Sin listener de evento scroll. Sin requestAnimationFrame. Sin cálculo de "porcentaje scrolleado". El binding animation-timeline: scroll() hace todo.
Revelado al hacer scroll#
Elementos que aparecen con fade al entrar en el viewport:
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}animation-timeline: view() vincula la animación a la visibilidad del elemento en el viewport. animation-range: entry 0% entry 100% significa que la animación se ejecuta desde el momento en que el elemento empieza a entrar al viewport hasta que es completamente visible.
Parallax sin biblioteca#
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from {
transform: translateY(-20%);
}
to {
transform: translateY(20%);
}
}
.parallax-section {
overflow: hidden;
position: relative;
}La imagen de fondo se mueve a una velocidad diferente al scroll, creando un efecto parallax. Suave, performante (el navegador puede componer en la GPU), y cero JavaScript.
Scroll timelines con nombre#
Para más control, puedes nombrar scroll timelines y referenciarlos desde otros elementos:
.scroller {
overflow-y: auto;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
/* Un elemento en cualquier parte del DOM puede referenciar este timeline */
.indicator {
animation: progress linear both;
animation-timeline: --my-scroller;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}Esto funciona incluso cuando el indicador y el scroller no son padre/hijo. Cualquier elemento puede vincularse a cualquier scroll timeline con nombre. Esto es poderoso para UIs de dashboard donde un panel scrolleable controla un indicador en un header fijo.
Anchor Positioning: Tooltips y popovers bien hechos#
Antes del anchor positioning, conectar un tooltip a su elemento trigger requería JavaScript. Calculabas posiciones con getBoundingClientRect(), manejabas offsets de scroll, gestionabas colisiones con el viewport, y recalculabas en resize. Bibliotecas como Popper.js (ahora Floating UI) existían específicamente porque esto era tan difícil de hacer bien.
El anchor positioning de CSS hace esto declarativo:
.trigger {
anchor-name: --my-trigger;
}
.tooltip {
position: fixed;
position-anchor: --my-trigger;
/* Posicionar el centro-superior del tooltip en el centro-inferior del trigger */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
/* Posicionamiento de fallback si desborda el viewport */
position-try-fallbacks: flip-block, flip-inline;
}Manejo automático de colisiones con el viewport#
La propiedad position-try-fallbacks es la parte que habría requerido 200 líneas de JavaScript. Le dice al navegador: "si el tooltip desborda el viewport por abajo, voltéalo arriba. Si desborda por la derecha, voltéalo a la izquierda."
.dropdown-menu {
position: fixed;
position-anchor: --menu-button;
/* Por defecto: debajo del botón, alineado al borde izquierdo */
top: anchor(bottom);
left: anchor(left);
/* Si no cabe abajo, intentar arriba. Si no cabe alineado a la izquierda, intentar derecha */
position-try-fallbacks: flip-block, flip-inline;
/* Añadir un espacio entre el anchor y el dropdown */
margin-top: 4px;
}Fallbacks de posición con nombre#
Para más control sobre las posiciones de fallback, puedes definir opciones de intento personalizadas:
@position-try --above-right {
bottom: anchor(top);
right: anchor(right);
top: auto;
left: auto;
margin-top: 0;
margin-bottom: 4px;
}
@position-try --left-center {
right: anchor(left);
top: anchor(center);
left: auto;
bottom: auto;
translate: 0 -50%;
margin-top: 0;
margin-right: 4px;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 4px;
position-try-fallbacks: --above-right, --left-center;
}El navegador prueba cada fallback en orden hasta encontrar uno que mantenga el elemento dentro del viewport. Este es el tipo de razonamiento espacial que era genuinamente doloroso en JavaScript.
Anclaje con la API de Popover#
El anchor positioning se combina perfectamente con la nueva API de Popover:
<button popovertarget="my-popover" style="anchor-name: --btn">Configuración</button>
<div popover id="my-popover" style="
position-anchor: --btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 8px;
">
Contenido del popover aquí
</div>Sin JavaScript para mostrar/ocultar (la API de Popover se encarga). Sin JavaScript para posicionamiento (el anchor positioning se encarga). Sin JavaScript para comportamiento de light-dismiss (la API de Popover también se encarga). El patrón completo de tooltip/popover/dropdown — un patrón que alimentó paquetes npm enteros — ahora es HTML y CSS.
CSS Grid Subgrid: Alineación anidada que realmente funciona#
Grid es poderoso, pero tenía una limitación frustrante: un grid hijo no podía alinear sus elementos al grid padre. Si tenías una fila de tarjetas y querías que el título, contenido y pie de cada tarjeta se alinearan entre tarjetas, no tenías suerte. El grid interno de cada tarjeta era independiente.
Subgrid arregla esto.
El problema de alineación de tarjetas#
/* Grid padre */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
/* Cada tarjeta se convierte en un subgrid, alineando filas al padre */
.card {
display: grid;
grid-row: span 3; /* La tarjeta abarca 3 filas en el padre */
grid-template-rows: subgrid;
gap: 0; /* La tarjeta controla su propio gap interno */
}
.card-title {
font-weight: 600;
padding: 1rem 1rem 0.5rem;
}
.card-body {
padding: 0 1rem;
color: oklch(40% 0 0);
}
.card-footer {
padding: 0.5rem 1rem 1rem;
margin-top: auto;
border-top: 1px solid oklch(90% 0 0);
}Ahora los títulos de las tres tarjetas se alinean en la misma fila. Los cuerpos se alinean. Los pies se alinean. Incluso cuando una tarjeta tiene un título de dos líneas y otra tiene uno de una línea, la alineación se mantiene por el grid padre.
Antes de subgrid: Los hacks#
Sin subgrid, lograr esta alineación requería:
- Alturas fijas (frágil, se rompe con contenido dinámico)
- Medición con JavaScript (lento, destello de desalineación)
- Rendirse y aceptar la desalineación (común, feo)
/* El viejo hack — frágil y se rompe con contenido dinámico */
.card-title {
min-height: 3rem; /* reza para que ningún título exceda esto */
}
.card-body {
min-height: 8rem; /* aún más rezos */
}Subgrid hace el hack innecesario. El grid padre distribuye las alturas de fila basándose en el contenido más alto de cada fila entre todas las tarjetas.
Column subgrid para layouts de formularios#
Subgrid funciona en columnas también, lo cual es perfecto para layouts de formularios:
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
}
.form-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.form-row label {
grid-column: 1;
text-align: right;
font-weight: 500;
}
.form-row input {
grid-column: 2;
padding: 0.5rem;
border: 1px solid oklch(80% 0 0);
border-radius: 0.375rem;
}Todas las labels se alinean. Todos los inputs se alinean. La columna de labels se auto-dimensiona al label más ancho. Sin anchos hardcodeados.
API de View Transitions: Navegación tipo SPA sin framework#
Esta es la funcionalidad más ambiciosa de esta lista. La API de View Transitions te permite animar entre navegaciones de página — incluyendo navegaciones entre documentos (clicks normales en links en una app multi-página). Tu sitio HTML estático ahora puede tener transiciones de página suaves y animadas.
Transiciones entre documentos#
Para habilitar view transitions entre documentos, añades una sola regla CSS a ambas páginas, la vieja y la nueva:
@view-transition {
navigation: auto;
}Eso es todo. El navegador ahora hará un cross-fade entre páginas al navegar. La transición por defecto es un fade de opacidad suave. Sin JavaScript. Sin framework. Solo dos líneas de CSS en cada página.
Personalizando la transición#
Puedes personalizar qué transiciones ocurren nombrando elementos específicos:
/* En ambas páginas */
.page-header {
view-transition-name: header;
}
.main-content {
view-transition-name: content;
}
.hero-image {
view-transition-name: hero;
}
/* Animaciones de transición */
::view-transition-old(content) {
animation: slide-out 0.3s ease-in both;
}
::view-transition-new(content) {
animation: slide-in 0.3s ease-out both;
}
::view-transition-old(hero) {
animation: fade-out 0.2s ease-in both;
}
::view-transition-new(hero) {
animation: none; /* El nuevo hero simplemente aparece */
}
@keyframes slide-out {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}Al navegar entre dos páginas que ambas tienen un .hero-image con view-transition-name: hero, el navegador automáticamente anima la imagen desde su posición en la página vieja hasta su posición en la nueva página. Es el patrón de "transición de elemento compartido" del desarrollo móvil, ahora en el navegador.
View Transitions en SPA#
Para aplicaciones de una sola página (React, Vue, Svelte, etc.), la API de JavaScript es directa:
/* Lado CSS — define tus animaciones de transición */
::view-transition-old(root) {
animation: fade-out 0.2s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-out;
}// Lado JS — envuelve tu actualización del DOM en startViewTransition
document.startViewTransition(() => {
// Actualiza el DOM aquí — render de React, swap de innerHTML, etc.
updateContent(newPageData);
});El navegador captura el estado viejo, ejecuta tu actualización, captura el nuevo estado, y anima entre ellos. Los elementos con nombre obtienen transiciones individuales; todo lo demás obtiene un crossfade por defecto.
Respetando las preferencias del usuario#
Siempre respeta prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Lo que puedes dejar de usar#
Estas funcionalidades colectivamente eliminan un montón de herramientas, bibliotecas y patrones en los que nos hemos apoyado durante años. Esto es lo que he eliminado o dejado de buscar:
Sass/SCSS para nesting y variables#
CSS tiene nesting nativo. CSS tiene custom properties (y las ha tenido durante años). Las dos razones principales por las que la gente recurría a Sass ahora están en el lenguaje. Si todavía usas Sass solo por $variables y nesting, puedes parar.
/* Antes: Sass */
$primary: #3b82f6;
.card {
background: white;
border: 1px solid lighten($primary, 40%);
&:hover {
border-color: $primary;
}
.title {
color: darken($primary, 15%);
}
}
/* Después: CSS nativo */
.card {
--primary: oklch(55% 0.2 260);
background: white;
border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
&:hover {
border-color: var(--primary);
}
.title {
color: color-mix(in oklch, var(--primary) 70%, black);
}
}La versión CSS es más poderosa — oklch da manipulación de color perceptualmente uniforme, color-mix funciona en runtime (el lighten de Sass es solo en tiempo de compilación), y las custom properties se pueden cambiar dinámicamente desde JavaScript o media queries.
JavaScript para animaciones de scroll#
Elimina tu código de IntersectionObserver para reveal-on-scroll. Elimina tu JavaScript de barra de progreso de scroll. Elimina tu handler de parallax. animation-timeline: scroll() y animation-timeline: view() manejan todo esto con mejor rendimiento (hilo del compositor, no hilo principal).
// Antes: JavaScript que puedes eliminar
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));/* Después: CSS que reemplaza todo lo anterior */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}Popper.js / Floating UI para posicionamiento#
Si estás usando Floating UI (antes Popper.js) solo para posicionamiento básico de tooltip/popover, el anchor positioning de CSS lo reemplaza. Pierdes algunas funcionalidades avanzadas (elementos virtuales, middleware personalizado), pero para el 90% de los casos — "pon este popover cerca de ese botón y mantenlo en el viewport" — CSS lo hace nativamente ahora.
Hacks de centrado con margin: auto#
Esto no es nuevo, pero sigo viendo margin: 0 auto por todas partes. Las herramientas modernas de layout lo hacen innecesario en la mayoría de los casos:
/* Forma antigua */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Mejor: CSS Grid o Flexbox */
.page {
display: grid;
place-items: center;
}
/* O: container queries hacen el contenedor responsive por sí mismo */
.content {
container-type: inline-size;
width: min(100% - 2rem, 1200px);
margin-inline: auto; /* OK, sigue siendo auto, pero margin-inline es más claro */
}margin: 0 auto sigue funcionando bien. Pero si te encuentras usándolo para centrado vertical o alineación compleja, recurre a flexbox o grid en su lugar.
Media queries para responsividad a nivel de componente#
Esta es la grande. Si estás escribiendo @media queries para hacer un componente responsive, probablemente lo estás haciendo mal ahora. Las container queries deberían ser tu opción por defecto para responsividad a nivel de componente. Reserva las @media queries para decisiones a nivel de página: cambios de layout, patrones de navegación, estilos de impresión.
/* Antes: Media query para estilizado de componentes (alcance incorrecto) */
@media (min-width: 768px) {
.product-card {
flex-direction: row;
}
}
/* Después: Container query (alcance correcto) */
.product-card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}El panorama general#
Estas funcionalidades no existen aisladas. Se componen. Container queries + :has() + nesting + oklch() + layers — usados juntos — te dan una experiencia de autoría de CSS que habría sido irreconocible hace cinco años:
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
--accent: oklch(55% 0.2 260);
display: grid;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(90% 0 0);
&:has(img) {
padding-top: 0;
img {
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
&:has(.badge.new) {
border-color: var(--accent);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
text-wrap: balance;
}
.meta {
font-size: 0.875rem;
color: oklch(50% 0 0);
}
@container (min-width: 500px) {
grid-template-columns: 200px 1fr;
&:has(img) {
padding: 0;
img {
border-radius: 0.5rem 0 0 0.5rem;
height: 100%;
aspect-ratio: auto;
}
}
.content {
padding: 1.5rem;
}
}
}
}Ese único bloque maneja:
- Layout responsive a nivel de componente (container queries)
- Estilizado condicional basado en contenido (
:has()) - Organización limpia de selectores (nesting)
- Colores perceptualmente uniformes (
oklch) - Gestión predecible de especificidad (
@layer)
Sin preprocesador. Sin runtime CSS-in-JS. Sin clases de utilidad para esto. Solo CSS, haciendo lo que CSS debería haber hecho desde siempre.
La plataforma se puso al día. Finalmente. Y valió la pena la espera.