Type something to search...
How to pass CSS variables from frontmatter with define:vars in Astro?

How to pass CSS variables from frontmatter with define:vars in Astro?

To pass CSS variables from frontmatter in Astro, add the define:vars directive to a component's <style> tag and give it an object of values: <style define:vars={{ width, color }}>. Each key becomes a CSS custom property with the same name, so inside the style block you write var(--width) and var(--color). The values can be any frontmatter variable, including props, which means every instance of a component can look different while its CSS stays static, scoped and cached.

It's the most direct bridge between server-side data and CSS in Astro, but it helps to know what it actually generates, because that explains both its strengths and its limits. In this article you'll learn the syntax, what the compiled HTML looks like, how it works with props and multiple instances, which values it can and can't pass, how define:vars behaves differently on <script> tags, and when a plain style attribute is the better choice. All examples were built and inspected with Astro 7.4.2.

The Basic Syntax

Here's a progress bar whose width and colour come from props:

---
// src/components/ProgressBar.astro
interface Props {
  value: number;
  color?: string;
}

const { value, color = "#16a34a" } = Astro.props;
const width = `${Math.min(Math.max(value, 0), 100)}%`;
---
<div class="track">
  <div class="bar"></div>
</div>

<style define:vars={{ width, color }}>
  .track {
    height: 0.5rem;
    background: #e5e7eb;
    border-radius: 999px;
  }

  .bar {
    width: var(--width);
    height: 100%;
    background: var(--color);
    border-radius: inherit;
  }
</style>

The object uses JavaScript shorthand, so {{ width, color }} is the same as {{ width: width, color: color }}. You can rename while passing, for example {{ barColor: color }}, and the property becomes --barColor. The name is used exactly as written, including its case.

Using the component is just passing props:

---
// src/pages/index.astro
import ProgressBar from "../components/ProgressBar.astro";
---
<ProgressBar value={30} />
<ProgressBar value={75} color="#2563eb" />

If you're new to typing props like this, the post on Astro component props covers the Props interface in detail.

What define:vars Actually Generates

This is the part most tutorials skip. Building the page above produced this HTML (formatted for readability):

<style>
  .track[data-astro-cid-uglr7svf]{background:#e5e7eb;border-radius:999px;height:.5rem}
  .bar[data-astro-cid-uglr7svf]{width:var(--width);background:var(--color);border-radius:inherit;height:100%}
</style>

<div class="track" style="--width: 30%;--color: #16a34a;" data-astro-cid-uglr7svf>
  <div class="bar" style="--width: 30%;--color: #16a34a;" data-astro-cid-uglr7svf></div>
</div>
<div class="track" style="--width: 75%;--color: #2563eb;" data-astro-cid-uglr7svf>
  <div class="bar" style="--width: 75%;--color: #2563eb;" data-astro-cid-uglr7svf></div>
</div>

Three things stand out:

  • The CSS is written once: the stylesheet still contains var(--width), not the value. It's shared by every instance, scoped as usual and bundled with the rest of the page's CSS.
  • Values go into style attributes: Astro adds the custom properties as an inline style on the elements in the component's template. Each instance gets its own values.
  • Every element in the template gets them: not just the root. That's harmless, but it does add bytes for large templates.

Because custom properties are set right on the elements, they don't depend on inheritance from a wrapper, and two instances on the same page can never clash.

Existing style Attributes Are Merged

If an element already has a style attribute, Astro appends the variables rather than replacing it. An element written as <span class="swatch" style="opacity: 0.9"> rendered as:

<span class="swatch" style="opacity: 0.9; --barColor: tomato;--size: 3;" data-astro-cid-b6zoaj54>x</span>

Slotted Content Doesn't Get the Variables

Elements passed in through <slot /> are written in the parent, so they don't receive the attributes. They can still read the variables through normal CSS inheritance if they sit inside an element that has them, which is usually what you want. Slots are covered in the post on Astro slots.

Which Values You Can Pass

define:vars on a style tag converts each value to a string for the style attribute. In my tests:

Value in frontmatterRendered asUseful?
"#16a34a"--color: #16a34a;Yes
`${30}%`--width: 30%;Yes
3--size: 3;Yes, combine with calc()
{ a: 1 }--opts: [object Object];No
undefined or nullOmitted entirelyFallbacks apply

So pass strings and numbers, and do any formatting in frontmatter. A unitless number is often the most flexible, because CSS can add the unit:

---
const { size = 3 } = Astro.props;
---
<span class="dot"></span>

<style define:vars={{ size }}>
  .dot {
    width: calc(var(--size) * 1rem);
    height: calc(var(--size) * 1rem);
  }
</style>

Because undefined values are skipped, you can rely on var() fallbacks for optional props:

.bar {
  background: var(--color, #16a34a);
}

Never Pass Untrusted Strings

The values are written into the HTML as-is. A colour that comes from a user, a query string or a CMS field should be validated before it reaches define:vars, for example by checking it against a list of allowed values or a strict pattern. The safest design is to accept a variant name as a prop, such as "success" or "warning", and map it to a colour in frontmatter.

A Practical Example: Theming a Component per Page

Because the values are plain frontmatter, they can come from anywhere you can reach on the server: props, content collection data, or a config file.

---
// src/components/Hero.astro
interface Props {
  title: string;
  accent?: string;
  image: string;
}

const { title, accent = "#4f46e5", image } = Astro.props;
const backgroundImage = `url("${image}")`;
---
<section class="hero">
  <h1>{title}</h1>
</section>

<style define:vars={{ accent, backgroundImage }}>
  .hero {
    padding: 6rem 1.5rem;
    background-image: linear-gradient(
        to bottom,
        color-mix(in srgb, var(--accent) 60%, transparent),
        transparent
      ),
      var(--backgroundImage);
    background-size: cover;
  }

  h1 {
    color: white;
    text-shadow: 0 2px 8px rgb(0 0 0 / 0.4);
  }
</style>

A product page could pass accent={product.data.brandColour} while a landing page passes its own, and the CSS file stays identical for all of them. Keep image to paths you control, for the reason given above.

define:vars on Script Tags

define:vars also works on <script>, but it behaves very differently there:

---
// src/components/Greeting.astro
const { name } = Astro.props;
---
<button class="greet">Say hi</button>

<script define:vars={{ name }}>
  document.querySelector(".greet").addEventListener("click", () => {
    alert(`Hi, ${name}!`);
  });
</script>

The built output:

<button class="greet">Say hi</button>
<script>(function(){const name = "Sajjad";

  document.querySelector(".greet").addEventListener("click", () => {
    alert(`Hi, ${name}!`);
  });
})();</script>

Values are serialised with JSON.stringify() and declared as constants inside a wrapper function. Using define:vars on a script implies is:inline, which means:

  • No bundling or TypeScript: the script is written into the HTML exactly as typed. Imports and type annotations won't work.
  • It runs once per instance: use the component three times and you get three copies of the script.
  • Nothing is deduplicated or cached as a separate file.

On a style tag, by contrast, define:vars does not imply is:inline: the CSS is still processed, scoped and bundled. For scripts, the docs recommend passing data through data-* attributes and reading them in a normal bundled script instead; that technique is explained in the post on passing data to scripts in Astro.

The Alternative: A style Attribute

Since define:vars works by writing inline style attributes, you can do the same thing by hand, with more control over where the variables go:

---
const { accent = "teal" } = Astro.props;
---
<div class="card" style={{ "--accent": accent, padding: "1rem" }}>
  <slot />
</div>
<div class="card" style="--accent:teal;padding:1rem">...</div>

Astro's style attribute accepts an object, and keys starting with -- are output as custom properties. The variable is set only on the root element and inherited by everything inside, including slotted content.

This is also the only option inside a loop, because define:vars applies the same values to every element in the component:

---
const items = [
  { name: "Design", hue: 200 },
  { name: "Code", hue: 20 },
];
---
<ul>
  {items.map((item) => <li style={`--hue: ${item.hue}`}>{item.name}</li>)}
</ul>

<style>
  li {
    color: hsl(var(--hue) 70% 40%);
  }
</style>

Which One to Use

SituationBetter choice
Component-wide values from propsdefine:vars
Values that differ per item in a liststyle attribute
Values that slotted content must inherit reliablystyle attribute on a wrapper
Value toggles between a few fixed statesclass:list with classes
Value must change in the browser after loadSet the property from a script

For state that maps to a handful of known styles, classes are usually clearer than variables; see the post on class:list in Astro.

Combining with Global Tokens and Sass

define:vars plays well with design tokens defined globally. A component can default to a site-wide token and let a prop override it:

---
const { accent } = Astro.props;
---
<a class="link"><slot /></a>

<style define:vars={{ accent }}>
  .link {
    color: var(--accent, var(--color-accent));
  }
</style>

When accent isn't passed, it's omitted from the output and the global --color-accent applies. With Sass, define:vars works on <style lang="scss"> as well, but remember that Sass functions like color.adjust() run at build time and can't operate on a var() value. Use CSS functions such as color-mix() for runtime values; the post on Sass in Astro compares the two kinds of variables.


FAQ: define:vars in Astro

It takes an object of frontmatter values and makes each one available as a CSS custom property in a style block, or as a JavaScript constant in a script block. Keys become property names, such as --width.

Yes. On a style tag the CSS is processed and scoped as normal, and only the values are written into inline style attributes on the component's elements. It doesn't imply is:inline for styles.

Not usefully to a style tag: an object is rendered as [object Object]. Pass strings or numbers instead, formatting them in frontmatter. On script tags values are JSON-serialised, so objects work there.

Using define:vars on a script implies is:inline, so it's written straight into the HTML and repeated for each component instance. Use data attributes and a normal script tag if you need bundling.

They're set once at render time. To change a value later, set the custom property from JavaScript with element.style.setProperty, and the CSS that reads it updates automatically.

Yes. A page is an Astro component too, so a style tag in a page can use define:vars with values from its own frontmatter, including data from content collections.


Conclusion

define:vars gives Astro components a clean way to style themselves from data: write <style define:vars={{ width, color }}> and read the values with var(--width) and var(--color). Under the hood Astro keeps one scoped stylesheet for all instances and writes the values into inline style attributes, so each instance gets its own look without duplicating CSS.

Pass strings and numbers, validate anything that didn't come from your own code, and lean on var() fallbacks for optional props. Use a style attribute when values differ per list item or must be inherited by slotted content, and keep define:vars away from scripts unless you really want an inline, per-instance script.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper