Variables CSS y define:vars
El puente entre el frontmatter y la hoja de estilos: las propiedades personalizadas de CSS como vocabulario compartido, la directiva define:vars para inyectar valores dinámicos desde el componente, su mecánica interna de declaración en línea sin hoisting, y el reparto sensato entre tokens estáticos y valores calculados.
Las variables CSS como puente entre dos mundos
Una propiedad personalizada —--acento: #7c3aed— es una variable que vive dentro del propio CSS. Se declara en un selector, se hereda por la cascada y se lee con var(--acento). A diferencia de las variables de un preprocesador, que se resuelven en build y desaparecen, estas existen en runtime: el navegador las conoce, puedes cambiarlas con JavaScript y responden a la cascada como cualquier otra propiedad.
:root { --acento: #7c3aed; --radio: 8px; }.cta { background: var(--acento); border-radius: var(--radio); }Esa persistencia es lo que las convierte en puente. Cuando declaras tus tokens de diseño en :root dentro de un global.css, creas un vocabulario que cualquier componente consume sin importar nada: un idioma común de colores, espaciados y radios. Pero ese puente, tendido a mano, solo transporta valores fijos. Para cruzar un dato que el componente calcula en su frontmatter, necesitas que Astro tienda el puente por ti.
Antes de automatizar ese puente, subraya una ventaja de que estas variables vivan en runtime: puedes cambiarlas después de cargar la página. Un solo gesto de JavaScript reasigna un token y, con él, todo lo que lo consume se repinta sin tocar una sola regla:
// cambiar un token en runtime repinta todo lo que depende de eldocument.documentElement.style.setProperty('--acento', '#0ea5e9');Esta es la base de los temas conmutables —claro y oscuro, por ejemplo—: una variable en :root que cambia y arrastra consigo la interfaz entera. La propiedad personalizada no es solo un puente estático, sino un punto de control vivo que el resto del CSS obedece.
define:vars: pasar el frontmatter al CSS
La directiva define:vars se coloca en un bloque <style> y recibe un objeto. Astro toma cada clave de ese objeto, la convierte en una propiedad personalizada homónima y la pone a disposición del bloque. Dentro del CSS la lees con var(--clave). Así, un color o un tamaño calculados en el frontmatter aterrizan en las reglas sin que escribas una sola línea de pegamento.
---interface Props { acento: string; radio: number; }const { acento, radio } = Astro.props;---<button class="cta">Comprar</button><style define:vars={{ acento, radio: `${radio}px` }}> .cta { background: var(--acento); border-radius: var(--radio); color: white; padding: 0.6rem 1.2rem; }</style>La correspondencia es literal: la clave acento se vuelve --acento, y la clave radio se vuelve --radio, sin transformaciones de mayúsculas ni conversiones ocultas. Cada instancia del componente puede recibir props distintas y, por tanto, pintar con valores distintos, aunque todas compartan exactamente el mismo CSS. El estilo es uno; los datos, muchos.
Por eso la firma del componente gana en expresividad: la apariencia deja de estar cableada en el CSS y pasa a formar parte del contrato de props. Quien use el botón decide su acento al invocarlo, igual que decidiría su texto, sin abrir el bloque de estilos ni conocer sus interioridades. La configurabilidad visual se vuelve parte de la interfaz pública del componente.
Qué ocurre por dentro (y qué implica)
Aquí aparece la consecuencia que distingue a un usuario avanzado. Como los valores de define:vars se calculan en el componente y pueden variar en cada render, Astro no puede agrupar ni compartir ese bloque como hace con los estilos normales. En su lugar, declara las propiedades personalizadas en línea, ancladas al ámbito del componente, y renderiza ese <style> junto al marcado en vez de izarlo a una hoja común. El bloque con define:vars queda, por diseño, fuera del mecanismo de hoisting.
En el HTML se ve sin ambigüedad: las variables aterrizan en el atributo style del propio elemento, mientras la regla que las usa vive, agrupada, en la hoja común.
<!-- la variable viaja en linea; la regla que la consume, no --><button class="cta" data-astro-cid-x style="--acento:#7c3aed;--radio:8px"> Comprar</button>De ahí una guía de higiene: mantén el bloque define:vars pequeño. Si un componente aparece cien veces, cien copias de un <style> gigante viajarán inline en el HTML, porque ninguna se puede deduplicar. La técnica correcta es separar responsabilidades: las reglas estáticas —lo que no cambia entre instancias— van en un <style> normal que Astro agrupa una sola vez, y solo las pocas líneas que consumen los valores dinámicos van en el bloque con define:vars. Así pagas inline únicamente por lo que de verdad varía.
La técnica en código se ve así: un <style> normal para lo estático —que Astro agrupa una sola vez— y un segundo bloque con define:vars, mínimo, solo con las reglas que consumen los valores dinámicos.
<style> .cta { padding: 0.6rem 1.2rem; font-weight: 600; color: white; }</style><style define:vars={{ acento }}> .cta { background: var(--acento); }</style>El primer bloque se comparte entre todas las instancias; el segundo, diminuto, es el único que viaja en línea. Repartir así el CSS es la diferencia entre un HTML esbelto y uno inflado por la duplicación.
Tokens estáticos frente a valores dinámicos
Con las piezas sobre la mesa, el reparto se vuelve claro. Cada valor de tu diseño encaja en una de tres categorías, y elegir bien la vía mantiene el CSS ligero y el sistema comprensible.
La tercera vía merece un ejemplo, porque cierra el círculo: la misma directiva que lleva datos al CSS puede llevarlos al cliente. En un <script>, define:vars serializa el objeto y lo hace disponible como variables en el ámbito del script, un canal limpio para pasar del servidor al navegador sin incrustar JSON a mano en el marcado.
---const usuario = { nombre: 'Ada', racha: 7 };---<script define:vars={{ usuario }}> console.log(`Hola ${usuario.nombre}, racha de ${usuario.racha}`);</script>Una última precisión sobre el <script>: como los valores se serializan a JSON, define:vars transporta bien datos —objetos planos, cifras, cadenas—, pero no funciones ni instancias con comportamiento. Es un canal para datos, no para lógica.