wandres.dev
PROPIEDADES TIPADAS · @property y el registro

@property: el registro y sus tres descriptores

Cómo se registra una custom property, qué cambia exactamente al hacerlo, por qué el valor inicial debe ser computacionalmente independiente y qué se rompe al registrar.

⏱ 17 min

Una custom property sin registrar es un flujo de tokens sin tipo, sin valor inicial y sin ninguna comprobación: acepta lo que le eches y el problema aparece más tarde, en cada sitio donde alguien la use. @property le pone un tipo, un valor inicial y una regla de herencia, y con eso cambia mucho más que la validación: cambia dónde se detecta el error, qué se puede animar y cuánto cuesta modificarla desde código.

🎯 Al terminar esta lección sabrás
  • Escribir una regla @property correcta y saber qué la invalida entera.
  • Enumerar las cuatro cosas que cambian al registrar una propiedad.
  • Explicar qué significa que el valor inicial sea computacionalmente independiente.
  • Anticipar qué patrones dejan de funcionar tras registrar.

La regla y sus tres descriptores

@property --acento {
  syntax: "<color>";
  inherits: false;
  initial-value: oklch(60% 0.18 260);
}

syntax e inherits son obligatorios. initial-value también lo es, salvo que syntax sea la sintaxis universal "*".

Y aquí está el primer sitio donde tropieza todo el mundo: si falta un descriptor obligatorio o alguno es inválido, la regla entera se ignora. No se registra a medias. El síntoma es desconcertante, porque la custom property sigue funcionando —como no registrada— y no hay ningún error visible: simplemente las animaciones no animan y las validaciones no validan.

Los tres errores más frecuentes:

/* MAL: syntax tiene que ser una cadena entrecomillada. */
@property --acento { syntax: <color>; inherits: false; initial-value: red; }

/* MAL: falta inherits, que no tiene valor por defecto. */
@property --acento { syntax: "<color>"; initial-value: red; }

/* MAL: falta initial-value y la sintaxis no es universal. */
@property --acento { syntax: "<color>"; inherits: false; }

/* BIEN. */
@property --acento { syntax: "<color>"; inherits: false; initial-value: red; }

El nombre tiene que ser un nombre válido de custom property, con sus dos guiones, y distingue mayúsculas de minúsculas como cualquier otra. Escribe las reglas @property en el nivel superior de la hoja, agrupadas al principio del archivo de tokens: es donde se buscan y donde se leen.

Si declaras dos veces la misma propiedad, gana la última, con las reglas normales de orden.

Qué cambia al registrar

Cuatro cosas, y solo una es la que la gente espera.

El valor se valida, y el error se contiene en el origen. Sin registrar, un valor malo envenena cada declaración que lo use: la sustitución produce algo inválido y la propiedad afectada cae a unset. Registrada, el valor malo se rechaza en la propia custom property, que cae a su initial-value, y todos los consumidores reciben un valor correcto.

@property --radio {
  syntax: "<length>";
  inherits: false;
  initial-value: 0.5rem;
}

.tarjeta {
  --radio: patata;          /* rechazado: --radio vale 0.5rem */
  border-radius: var(--radio);   /* 0.5rem, no unset */
}

Compara con la versión sin registrar, donde border-radius acabaría en su valor inicial, 0, y encima sin que nada lo indique.

La herencia deja de ser obligatoria. Sin registrar, toda custom property se hereda. Con inherits: false deja de hacerlo, y eso tiene dos consecuencias: los descendientes ven el initial-value en lugar del valor del ancestro, y cambiar la propiedad solo invalida el elemento donde se cambia, no su subárbol.

Aparece un valor inicial de verdad. Sin registrar, una propiedad no declarada es garantizadamente inválida y var(--x) sin fallback rompe la declaración. Registrada, siempre hay algo: el initial-value. Los fallbacks dejan de ser necesarios.

El valor pasa a ser computado. Sin registrar, el valor calculado es el flujo de tokens tal cual: calc(2 * 1rem) sigue siendo esa cadena. Registrada como <length>, el motor la resuelve a píxeles absolutos. Eso es lo que permite interpolar, y es también lo que devuelve getComputedStyle.

/* Sin registrar: la cadena "calc(2 * 1rem)". */
/* Registrada como <length>: "32px". */
getComputedStyle(el).getPropertyValue('--tamano');

initial-value y la independencia computacional

El valor inicial tiene que ser computacionalmente independiente: convertible a valor calculado sin necesitar ningún otro valor del elemento.

/* BIEN: px es absoluto. */
@property --hueco { syntax: "<length>"; inherits: true; initial-value: 16px; }

/* MAL: em depende del tamano de fuente del elemento. */
@property --hueco { syntax: "<length>"; inherits: true; initial-value: 1em; }

/* MAL: rem depende del tamano de fuente de la raiz. */
@property --hueco { syntax: "<length>"; inherits: true; initial-value: 1rem; }

La razón es que el valor inicial se necesita antes de tener contexto: es lo que vale la propiedad en un elemento que no la declara, y en ese momento no hay ninguna garantía de que se haya resuelto el tamaño de fuente. Un valor inicial dependiente convertiría el registro en circular.

Tampoco puedes poner var() dentro de initial-value, por lo mismo.

Cuando el valor por defecto que quieres es relativo, la salida es declarar el valor inicial en absoluto y poner el relativo en una regla normal:

@property --hueco { syntax: "<length>"; inherits: true; initial-value: 0px; }
:root { --hueco: 1rem; }
⚠️
Registrar es global, no por ámbito

El registro afecta al documento entero. No puedes tener --tono como <color> en un componente y como <length> en otro; hay un único registro por nombre. Eso obliga a una disciplina de nombres más estricta de la que hacía falta antes: los parámetros con significados distintos necesitan nombres distintos, y si dos librerías registran el mismo nombre con sintaxis incompatibles, la última cargada gana y la otra se rompe en silencio.

Los efectos secundarios

Registrar no es gratis y conviene saber qué deja de funcionar.

El interruptor de espacio se rompe. Una propiedad registrada con sintaxis concreta no puede contener el valor vacío ni aceptar initial como marca, así que el truco de la lección anterior deja de conmutar. Falla eligiendo siempre la misma rama, sin error. Si usas ese patrón, registra únicamente las propiedades que no participan en él, o usa syntax: "*", que conserva el comportamiento de flujo de tokens.

Las herramientas muestran otra cosa. El valor calculado que ves en el inspector y el que devuelve getComputedStyle ya no es lo que escribiste sino su forma computada. Es mejor para depurar valores y peor para depurar de dónde salió un valor.

Los valores heredados se resuelven antes. Con inherits: true y una sintaxis de longitud, lo que se hereda es la longitud ya resuelta en píxeles, no la expresión. Si contabas con que 1em se reinterpretara en cada descendiente según su propia fuente, deja de ocurrir.

Sobre el soporte, @property llegó a Chrome en la versión 85, a Safari en 16.4 y a Firefox en la 128, en julio de 2024. En 2026 está ampliamente disponible. Y su modo de fallo es benigno, al contrario que el de @scope: un motor sin soporte ignora la regla @property, la custom property sigue existiendo como no registrada, y todo lo que no dependa de animar o de validar sigue funcionando igual.

Registrar mueve el error del consumidor a la definición

La ventaja que se cita siempre de @property es poder animar gradientes, y es la menos importante. La grande es un cambio en dónde se manifiesta un error, y es exactamente el mismo argumento que justifica los tipos estáticos en un lenguaje de programación. Sin registro, una custom property es un valor sin contrato: quien la define puede poner lo que quiera, y la consecuencia de poner algo malo no la sufre él sino los veinte sitios que la consumen, cada uno a su manera, cada uno con un síntoma distinto —aquí un color que se hereda, allá un fondo transparente, más allá una abreviada entera que desaparece—, y ninguno de esos veinte sitios menciona la causa. Depurarlo consiste en recorrer el árbol hacia arriba buscando quién escribió mal un valor que ni siquiera aparece en el panel de estilos del elemento afectado. Con registro, el contrato existe y se comprueba en el punto donde se asigna el valor: el valor malo se rechaza allí mismo, la propiedad conserva algo utilizable, y los veinte consumidores siguen funcionando. Es la diferencia entre un error que se propaga y uno que se contiene. De ahí sale el criterio para decidir qué registrar, que no es “todo” ni “nada”: registra lo que cruza una frontera. Los parámetros públicos de un componente, los tokens del sistema de diseño, cualquier propiedad que vaya a escribirse desde JavaScript o desde una plantilla, todo lo que otra persona pueda asignar sin leer tu implementación. Lo puramente interno de un bloque, que nadie de fuera va a tocar, no necesita registro y no ganas nada registrándolo. Escrito así se parece sospechosamente a la regla que ya aplicas al decidir qué tipar en un lenguaje gradual, y no es casualidad: es el mismo problema.

⚔️ Registra y rompe
  1. Escribe una regla @property a la que le falte inherits y comprueba que se ignora entera y sin aviso.
  2. Asigna un valor inválido a una propiedad registrada y verifica que cae al inicial y no a unset.
  3. Intenta usar 1em como initial-value y explica por qué no se acepta.
  4. Compara lo que devuelve getComputedStyle para la misma propiedad registrada y sin registrar.
  5. Registra una propiedad que participe en un interruptor de espacio y observa cómo deja de conmutar.