Skip to content
Astro as scripts 4 min read

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 your var() 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:vars are inlined, they are not deduplicated or bundled. Prefer passing data through data-* attributes and reading them in a bundled script when you only need DOM values; reserve define:vars on scripts for genuine inline needs.

Directive reference

TargetEffectNotes
<style define:vars={...}>Injects custom properties as an inline style attributeValues must be JSON-serializable; keys become --key
<script define:vars={...}>Exposes values as variables in an inlined scriptForces inlining; no bundling or dedup
var(--name) referenceReads the injected property in CSSCasing 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:vars does not transform names.
  • Prefer define:vars over inline style attributes 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 use data-* attributes plus a bundled script to preserve caching and dedup.
  • Combine define:vars with :root-level custom properties from global styles so component overrides cascade predictably.
  • Only pass serializable values — functions, Date instances, and circular structures cannot be injected.
Last updated June 14, 2026
Was this helpful?