Keyframes con custom properties y @property
Por qué una variable CSS animada salta en vez de interpolar, y cómo @property le da un tipo para que el motor sepa cómo mezclar dos valores.
Animar una custom property parece que debería funcionar y no funciona: el valor cambia de golpe a mitad de camino en vez de deslizarse. No es un bug ni una limitación arbitraria. Una custom property sin registrar es, para el motor, una cadena de tokens sin tipo, y no hay forma de calcular el punto medio entre dos cadenas. @property existe precisamente para resolver eso, y con él las variables pasan a ser el mecanismo más potente que tiene el CSS para componer animaciones.
- Explicar por qué una custom property sin registrar interpola de forma discreta.
- Registrar una propiedad con
@propertyindicandosyntax,inheritseinitial-value. - Animar un ángulo, una longitud y un color a través de variables registradas.
- Valorar el coste de rendimiento de animar una custom property.
Sin tipo no hay interpolación
Una declaración como --desplazamiento: 40px no guarda una longitud. Guarda la secuencia de tokens 40px, sin interpretar. Esa es la definición de custom property en la especificación: un valor de sintaxis universal que solo se resuelve cuando alguien lo sustituye con var() dentro de una propiedad que sí tiene tipo.
La consecuencia directa es que el motor no puede interpolar. Para calcular el valor a mitad de camino entre 40px y 0px haría falta saber que son longitudes; lo único que sabe es que son dos listas de tokens distintas. Y el comportamiento definido para valores no interpolables es la interpolación discreta: el valor de origen se mantiene hasta el 50% del tramo y a partir de ahí salta al de destino.
/* Salta a mitad de tramo. No es un bug. */
@keyframes deslizar {
from { --desplazamiento: 40px; }
to { --desplazamiento: 0px; }
}
.caja {
transform: translateY(var(--desplazamiento));
animation: deslizar 600ms;
}
Merece la pena entender por qué el salto es al 50% y no al final: la interpolación discreta está definida como una función escalón cuyo punto de conmutación es el 50% del progreso del tramo, después de aplicar la función de temporización. Eso significa que un ease-in mueve el instante del salto en el tiempo real, aunque siga siendo el 50% del progreso. Lo mismo pasa con visibility, con display y con cualquier keyword no interpolable: es el mismo mecanismo, no un caso especial de las variables.
Registrar la propiedad
@property le da un tipo a una custom property. Al registrarla dejas de tener una cadena de tokens y pasas a tener un valor tipado que el motor sabe validar, heredar e interpolar.
@property --desplazamiento {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@keyframes deslizar {
from { --desplazamiento: 40px; }
to { --desplazamiento: 0px; }
}
Los tres descriptores son obligatorios salvo en un caso. syntax declara el tipo con la gramática de valores de CSS: "<length>", "<number>", "<color>", "<angle>", "<percentage>", "<length-percentage>", "<integer>", "<time>", "<resolution>", "<transform-function>", "<image>", "<url>", "<custom-ident>". Puedes componer con + para listas separadas por espacios, # para listas separadas por comas, y | para alternativas, incluyendo palabras clave literales.
inherits es booleano y no tiene valor por defecto: hay que escribirlo. initial-value es obligatorio para cualquier syntax que no sea "*"; si lo omites, la regla @property entera es inválida y se descarta, y te quedas con la propiedad sin registrar y sin ningún aviso más allá de la consola. Es el fallo más frecuente al empezar: la animación sigue saltando y la regla parece correcta.
El equivalente en JavaScript es CSS.registerProperty(), con las mismas restricciones y con la particularidad de que lanza una excepción si intentas registrar dos veces el mismo nombre. En una aplicación con módulos que se cargan más de una vez, envuelve la llamada en un try.
try {
CSS.registerProperty({
name: '--desplazamiento',
syntax: '<length>',
inherits: false,
initialValue: '0px',
});
} catch {
// ya estaba registrada
}
Lo que se desbloquea
Con variables registradas puedes animar cosas que el CSS no expone como propiedades animables. El caso canónico es el ángulo de un degradado cónico, que no es una propiedad sino una parte del valor de background-image:
@property --angulo {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes girar-borde {
to { --angulo: 360deg; }
}
.tarjeta {
background: conic-gradient(from var(--angulo), #cba6f7, #89b4fa, #cba6f7);
animation: girar-borde 4s linear infinite;
}
Sin registrar la variable, el degradado saltaría de 0 a 360 grados a mitad de animación, que visualmente es no moverse nada. Con ella registrada como <angle>, el borde gira de forma continua. Fíjate en que aquí no hay keyframe del 0%: el implícito lo aporta el initial-value, o el valor computado si el elemento declara uno.
El segundo caso es descomponer un transform en ejes independientes con curvas distintas, que es justo lo que no se puede hacer dentro de una sola lista de transform:
@property --x { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --y { syntax: "<length>"; inherits: false; initial-value: 0px; }
@keyframes arco {
0% { --x: 0px; --y: 0px; animation-timing-function: linear; }
100% { --x: 300px; --y: -80px; animation-timing-function: ease-out; }
}
.pelota {
transform: translate(var(--x), var(--y));
animation: arco 900ms forwards;
}
Cada eje es ahora una propiedad con su propia lista de keyframes, así que puedes darles offsets y curvas distintas y obtener una trayectoria curva a partir de dos movimientos rectos. Es el mismo truco que usan las librerías de animación por debajo, expresado en CSS puro.
Animar una custom property registrada no se puede acelerar en el compositor, y esto no tiene excepciones. La razón es estructural: la variable participa en la sustitución de var(), que ocurre en la fase de cálculo de estilo. Para conocer el transform de un frame hay que resolver la variable, y resolver la variable exige recalcular el estilo. Así que una animación de --x que alimenta un translate produce un recálculo de estilo por frame en el hilo principal, mientras que la misma animación escrita directamente sobre transform vive en el compositor y sobrevive a un hilo principal bloqueado. En una tarjeta aislada da igual. En una lista de doscientos elementos, la versión con variables cae a treinta frames por segundo y la versión directa no se entera. La regla: usa variables registradas cuando necesites expresar algo que no se puede expresar de otro modo —el ángulo del cónico, los ejes con curvas separadas— y nunca porque quede más limpio.
El detalle de la herencia
inherits: true en una variable animada tiene un efecto que no es obvio: la animación del padre se propaga a todos los descendientes y cada uno recalcula su estilo en cada frame. Si la variable alimenta una propiedad que solo usa el padre, estás pagando el recálculo de todo el subárbol para nada.
Por defecto, declara inherits: false. Solo pon true cuando de verdad quieras que los hijos lean el valor animado, y aun entonces piensa si no sería más barato aplicar la animación directamente en los hijos. La herencia de una variable animada es una de las formas más fáciles de convertir una animación barata en un recálculo de estilo de mil nodos por frame.
Registra --brillo como "<number>" con initial-value: 1 y anímala de 1 a 1.6 en un elemento que use filter: brightness(var(--brillo)). Después escribe la misma animación directamente sobre filter: brightness(1.6). Abre el panel de rendimiento y compara: la segunda no genera recálculo de estilo por frame, la primera sí. Ese es el precio exacto de la indirección.