CSS Variables & define:vars
Astro renders most components to static HTML and CSS at build time, which leaves an obvious question: how do you feed a runtime-or-render-time value, like a prop or a computed color, into a <style> block? The define:vars directive answers this by serializing values from the component script into CSS custom properties, letting your scoped styles stay declarative while still reacting to data. This keeps the zero-JS-by-default philosophy intact — there is no client-side scripting required to apply per-instance styling.
How define:vars works
When you add define:vars={{ ... }} to a <style> or <script> tag, Astro takes each key in the object and exposes it as a CSS custom property scoped to the element. The values are evaluated in the component’s frontmatter (server side), JSON-serialized, and injected via an inline style attribute on the component’s root element. Inside the stylesheet you reference them with the standard var(--name) syntax.
---
const textColor = "#7c3aed";
const fontSize = "1.5rem";
const spacing = 24;
---
<p class="callout">Styled with server-computed values.</p>
<style define:vars={{ textColor, fontSize, spacing: `${spacing}px` }}>
.callout {
color: var(--textColor);
font-size: var(--fontSize);
padding: var(--spacing);
}
</style>
Astro emits an inline style="--textColor:#7c3aed;--fontSize:1.5rem;--spacing:24px" on the wrapping element, and the scoped class rule reads those variables. The CSS itself remains static and cacheable; only the small set of custom properties varies per render.
Object keys become the custom property names verbatim.
define:vars={{ textColor }}produces--textColor, not--text-color. Match the casing exactly in yourvar()calls.
Per-instance styling with props
The real power shows up when values come from props. Each rendered instance of the component gets its own custom properties, so a single style block adapts to many configurations without duplicating CSS.
---
interface Props {
color: string;
width?: number;
}
const { color, width = 100 } = Astro.props;
---
<div class="bar"><slot /></div>
<style define:vars={{ color, width: `${width}%` }}>
.bar {
background: var(--color);
width: var(--width);
height: 0.5rem;
border-radius: 999px;
}
</style>
Using that component multiple times produces independently styled elements from the same compiled stylesheet:
---
import Bar from "../components/Bar.astro";
---
<Bar color="#22c55e" width={75} />
<Bar color="#ef4444" width={40} />
Theming and computed values
Because values are computed in the frontmatter, you can derive properties from data — for example, generating a theme from a single brand color or reading from a content collection entry.
---
const brand = "#0ea5e9";
const theme = {
brand,
surface: "#0f172a",
border: "rgba(255,255,255,0.1)",
};
---
<section class="panel">
<h2>Dashboard</h2>
<slot />
</section>
<style define:vars={theme}>
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid var(--brand);
padding: 1.5rem;
border-radius: 0.75rem;
}
</style>
You can pass an object directly (as above) rather than an inline literal — any object whose values are JSON-serializable works.
Using define:vars on scripts
The same directive works on <script> tags, exposing the values as CSS custom properties on document scope so client-side code can read them. Note that on <script>, define:vars forces the script to be inlined (rather than bundled), because the values must be present in the HTML.
---
const message = "Hydration complete";
const retries = 3;
---
<script define:vars={{ message, retries }}>
console.log(message, "with", retries, "retries");
</script>
Output:
Hydration complete with 3 retries
Because scripts with
define:varsare inlined, they are not deduplicated or bundled. Prefer passing data throughdata-*attributes and reading them in a bundled script when you only need DOM values; reservedefine:varson scripts for genuine inline needs.
Directive reference
| Target | Effect | Notes |
|---|---|---|
<style define:vars={...}> | Injects custom properties as an inline style attribute | Values must be JSON-serializable; keys become --key |
<script define:vars={...}> | Exposes values as variables in an inlined script | Forces inlining; no bundling or dedup |
var(--name) reference | Reads the injected property in CSS | Casing must match the object key exactly |
Best practices
- Pass already-formatted strings for values that need units (
`${n}px`); raw numbers become unitless custom properties. - Keep object keys camelCase and reference them identically —
define:varsdoes not transform names. - Prefer
define:varsover inlinestyleattributes when the value drives a scoped rule, so the styling logic lives with the rest of your CSS. - Reserve
<script define:vars>for cases that truly need inlined data; otherwise usedata-*attributes plus a bundled script to preserve caching and dedup. - Combine
define:varswith:root-level custom properties from global styles so component overrides cascade predictably. - Only pass serializable values — functions,
Dateinstances, and circular structures cannot be injected.