Contratos de componente con custom properties
Diseñar la API pública de un componente CSS: qué se expone, qué se protege, cómo se documenta y por qué :where() convierte tus valores por defecto en algo que el consumidor puede vencer.
Un componente CSS tiene una API tanto si lo has diseñado como si no. La diferencia es que, si no la has diseñado, la API es “todo lo que se pueda alcanzar con un selector”, y entonces cualquier consumidor puede depender de tu estructura interna y tú no puedes cambiarla nunca. Diseñarla consiste en elegir explícitamente qué puede configurarse desde fuera, garantizar que eso siga funcionando, y hacer que el resto sea difícil de tocar por accidente. Las custom properties son la herramienta, y tienen una propiedad que ninguna otra tiene: atraviesan la frontera del componente hacia dentro.
- Diseñar la superficie configurable de un componente y justificar cada token.
- Escribir valores por defecto que el consumidor pueda sobrescribir sin luchar.
- Usar
:where()y@layerpara que el componente nunca gane una guerra de especificidad. - Documentar el contrato de forma que sea verificable.
Qué es la API de un componente CSS
Todo lo que un consumidor puede hacer para cambiar tu componente sin editarlo. En la práctica hay tres superficies, y solo una es una buena API:
Los selectores. El consumidor escribe .mi-pagina .boton span { … } y depende de que el botón tenga un span dentro. Es la peor: no la has autorizado, no puedes saber quién la usa, y el día que cambies la estructura interna rompes a gente que no conoces. Se defiende dificultando el acoplamiento, no prohibiéndolo.
Las clases modificadoras. El consumidor añade .boton--peligro. Es una API real, es discreta —un conjunto cerrado de valores— y está bien para variantes que tú has previsto. Su límite es que no compone: veinte modificadores independientes son un millón de combinaciones que no puedes escribir.
Las custom properties. El consumidor asigna --boton-fondo. Es continua, compone sin explosión combinatoria, y —esto es lo decisivo— funciona desde fuera hacia dentro por herencia, así que el consumidor puede configurar el componente desde un ancestro sin tocar el elemento ni conocer su estructura.
Un contrato bien diseñado usa las dos últimas: modificadores para las variantes conceptuales, custom properties para los ajustes continuos.
Escribir el contrato
La forma canónica tiene cuatro piezas y merece la pena verlas juntas antes de desglosarlas:
@layer componentes {
@property --boton-fondo {
syntax: "<color>";
inherits: false;
initial-value: transparent;
}
:where(.boton) {
/* API publica: valores por defecto que apuntan a semanticos */
--boton-fondo: var(--color-accion);
--boton-texto: white;
--boton-relleno-bloque: var(--esp-2);
--boton-relleno-linea: var(--esp-4);
--boton-radio: var(--radio-md);
/* Implementacion: no forma parte del contrato */
display: inline-flex;
align-items: center;
gap: 0.5em;
background: var(--boton-fondo);
color: var(--boton-texto);
padding: var(--boton-relleno-bloque) var(--boton-relleno-linea);
border-radius: var(--boton-radio);
border: 0;
font: inherit;
cursor: pointer;
}
:where(.boton:hover) {
--boton-fondo: var(--color-accion-hover);
}
:where(.boton--fantasma) {
--boton-fondo: transparent;
--boton-texto: var(--color-accion);
box-shadow: inset 0 0 0 1px currentColor;
}
}
Primera pieza: el bloque de tokens al principio. Todas las custom properties configurables, juntas, con sus valores por defecto apuntando a semánticos. Ese bloque es la documentación del contrato: quien abre el fichero ve en diez líneas qué se puede tocar.
Segunda: :where() en todos los selectores. Esto hace que la especificidad del componente sea cero. Cualquier regla que el consumidor escriba, por simple que sea, gana. Es contraintuitivo la primera vez —parece que estás renunciando a defender tu diseño— y es exactamente lo que quieres: un componente que gana las guerras de especificidad es un componente que obliga a sus consumidores a escribir !important.
Tercera: la capa. Aunque :where() ya deja la especificidad en cero, la capa protege del caso en que el consumidor escriba su CSS en una capa anterior a propósito. Con @layer componentes declarado en su sitio, el orden es explícito y no depende de quién cargue antes.
Cuarta: el estado también pasa por tokens. Fíjate en que :hover no cambia background, cambia --boton-fondo. La diferencia es enorme: si un consumidor sobrescribe --boton-fondo, el hover sigue funcionando con el mecanismo del componente. Si el hover hubiera tocado background directamente, el consumidor tendría que reimplementar el hover también.
Cada propiedad CSS debería aplicarse una sola vez en el componente, leyendo de un token. Todos los estados, variantes y contextos cambian el token, nunca la propiedad. Eso convierte el componente en una función: entran tokens, sale un aspecto. Y hace que cualquier configuración del consumidor sobreviva a todos los estados sin que él tenga que enumerarlos.
Qué exponer y qué no
Exponer poco es mejor que exponer mucho, por la misma razón que en cualquier API: todo lo que expones es algo que no puedes cambiar. Si expones --boton-relleno-superior, has prometido que el botón tendrá relleno superior para siempre.
Criterios útiles:
Expón lo que varía por contexto legítimo. Colores, espaciado, radio, tipografía. Son cosas que un producto ajusta y que no cambian la naturaleza del componente.
No expongas la estructura interna. --boton-direccion-flex es una filtración: convierte una decisión de implementación en contrato. Si de verdad hace falta cambiar la disposición, eso es otra variante, no un token.
No expongas lo que rompería la accesibilidad. El grosor del anillo de foco, sí. La existencia del anillo de foco, no; eso no debe ser configurable.
Expón los tokens en la unidad correcta. Un token de espaciado que se documenta como “un paso de la escala” debe recibir un token de escala, no un número. Si expones --boton-relleno: 13px como uso válido, has abierto la puerta a salirse del sistema por la vía oficial.
Y un detalle que se olvida siempre: da nombre al componente en el prefijo del token. --boton-fondo, no --fondo. Sin prefijo, un componente dentro de otro hereda tokens del padre por accidente, y ese es un bug precioso: el botón dentro de una tarjeta adopta el fondo de la tarjeta porque los dos usan --fondo. Registrar con inherits: false es la segunda defensa, pero el prefijo es la primera y es gratis.
Documentar de forma verificable
La documentación de un contrato de CSS envejece mal porque nada la comprueba. Dos técnicas hacen que envejezca menos.
El bloque de tokens como fuente. Si la única definición de los valores por defecto está en el bloque de la parte superior del componente, la documentación se genera de ahí y no puede desincronizarse. Es la razón principal para agrupar todos los tokens al principio en lugar de declararlos donde se usan.
@property como especificación de tipo. Registrar un token documenta su tipo de forma que el motor lo hace cumplir:
@property --boton-radio {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0.5rem;
}
Ahora --boton-radio: rojo no produce un componente roto de forma silenciosa: se rechaza y el botón conserva su radio. El contrato deja de ser una promesa en un comentario y pasa a ser algo que el navegador verifica en cada asignación.
Para el caso de un componente que se distribuye fuera de tu proyecto, hay una última precaución: cada token debe llevar una reserva a un valor literal en la última posición de la cadena, para que el componente funcione en un proyecto que no tenga tus semánticos.
--boton-fondo: var(--color-accion, oklch(0.55 0.18 265));
Con eso, el componente pegado en un proyecto vacío se ve razonable; con tus tokens presentes, se integra.
Merece la pena entender por qué esta técnica no tiene sustituto, porque explica su papel central en el CSS de la última década y también dónde deja de servir. Todos los demás mecanismos de CSS van hacia fuera o hacia los lados: un selector alcanza descendientes desde arriba, una regla más específica vence a otra, una capa gana a otra. Ninguno permite que un consumidor configure el interior de un componente sin conocer su estructura. Las custom properties sí, y lo hacen por una razón concreta: se heredan y se resuelven en el punto de uso. Cuando el consumidor escribe --boton-fondo en un ancestro cualquiera, ese valor viaja por herencia hasta cada botón del subárbol y se resuelve allí, dentro de reglas que el consumidor nunca ha visto. Es inyección de dependencias, con el árbol del DOM como contenedor y la herencia como mecanismo de resolución, y es la razón por la que un componente bien escrito puede ser configurable sin exponer un solo detalle interno. Ahora el límite, que también conviene tener claro: esa herencia no distingue quién eres. Si dos componentes distintos usan un token con el mismo nombre, la configuración destinada a uno alcanza al otro, y no hay ámbito, ni privacidad, ni forma de decir “esto es mío”. Los prefijos y inherits: false son parches sobre esa carencia, no soluciones. Es la misma carencia que tienen todos los sistemas basados en un contexto ambiental global, y la disciplina que la compensa es idéntica a la de cualquiera de ellos: nombres que no colisionan por construcción, y una lista escrita de qué nombres son públicos. Sin esas dos cosas, tu contrato no es una API: es una zona de nombres compartida donde el último que escribe gana.