
How does scoped CSS work in Astro components?
Scoped CSS in Astro means that any <style> tag you write inside an .astro component only applies to the HTML in that same component. At build time Astro gives every element in the component a unique attribute, such as data-astro-cid-yk4hkwyg, and rewrites each selector in the style block to require that attribute. An h2 { color: red; } rule becomes h2[data-astro-cid-yk4hkwyg], so it can't touch any other h2 on the page. You don't have to configure anything: scoping is the default for every <style> tag in an Astro component.
This is one of the reasons Astro components are pleasant to style. You can use short, low-specificity selectors like h2, p or .title without worrying about collisions, and the CSS is bundled and optimised for you. In this article you'll learn what the compiler actually does to your styles, how scoping interacts with slots and child components, how to opt out with :global() and is:global, how to pass a class into a child component, how the three scoping strategies affect specificity, and how scoped styles fit into the cascade with imported stylesheets.
A Scoped Style in Practice
Here's a small card component with a style block:
---
// src/components/Card.astro
const { title } = Astro.props;
---
<article class="card">
<h2>{title}</h2>
<slot />
</article>
<style>
.card {
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
}
h2 {
margin-top: 0;
color: rebeccapurple;
}
</style>
And a page that uses it, with its own h2 outside the card:
---
// src/pages/index.astro
import Card from "../components/Card.astro";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Scoped CSS</title>
</head>
<body>
<h2>Page heading</h2>
<Card title="Hello">
<p>Card body</p>
</Card>
</body>
</html>
After npx astro build, the relevant parts of dist/index.html look like this (whitespace added for readability):
<style>
.card[data-astro-cid-yk4hkwyg]{border:1px solid #ddd;border-radius:.5rem;padding:1.5rem}
h2[data-astro-cid-yk4hkwyg]{color:#639;margin-top:0}
</style>
<h2 data-astro-cid-lcdefpme>Page heading</h2>
<article class="card" data-astro-cid-yk4hkwyg>
<h2 data-astro-cid-yk4hkwyg>Hello</h2>
<p data-astro-cid-lcdefpme>Card body</p>
</article>
Three things are worth noticing:
- Every component gets its own ID: the card's elements carry
yk4hkwyg, while the page's elements carrylcdefpme. The ID is a hash, so it stays stable between builds as long as the component doesn't change. - Only the card's
h2matches: the page heading has a different attribute, so the purple colour never reaches it. - The CSS was minified and normalised:
rebeccapurplebecame#639. Astro 7's Rust compiler serialises some colour values andurl()quotes differently from older versions, but the visual result is identical.
Small stylesheets like this one are inlined into a <style> tag in the page head. Larger ones are emitted as separate files in dist/_astro/ and linked with <link rel="stylesheet">. By default the cut-off is 4kB, and you can change it with build.inlineStylesheets ('auto', 'always' or 'never').
What Scoping Does and Doesn't Cover
The scope boundary is the component file. That has a few consequences that surprise people at first.
Slotted Content Belongs to the Parent
Look at the <p>Card body</p> in the output above. It's rendered inside the card's <article>, but it carries the page's ID (lcdefpme), not the card's. Slotted markup is written in the parent, so it's scoped to the parent. A p { ... } rule inside Card.astro won't style it. If you need the card to style whatever is passed in, use :global(), covered below. Slots themselves are explained in more detail in the post on Astro slots.
Child Components Are Not Affected
If Card.astro renders another Astro component, say <Badge />, the card's styles don't reach into the badge's markup. Each component styles itself. The usual fix, if you want to position or space a child from the outside, is to wrap it in an element you own:
<div class="badge-slot">
<Badge />
</div>
<style>
.badge-slot {
position: absolute;
top: 1rem;
right: 1rem;
}
</style>
Framework Components Follow Their Own Rules
Scoping applies to .astro files. React, Vue and Svelte components handled by an integration use their own styling systems: CSS Modules (.module.css) for React and Preact, <style scoped> for Vue, and Svelte's built-in component scoping.
Opting Out with :global() and is:global
Sometimes you really do want unscoped CSS. Astro gives you two tools for this.
The :global() Selector
:global() lets you mix scoped and unscoped parts in one selector. The part outside :global() stays scoped; the part inside doesn't. This is ideal for styling content you don't write yourself, such as rendered Markdown or CMS HTML passed through a slot:
---
// src/components/Prose.astro
---
<div class="prose">
<slot />
</div>
<style>
.prose :global(a) {
color: teal;
text-decoration-thickness: 2px;
}
.prose :global(h2) {
font-size: 1.5rem;
}
</style>
The compiled CSS shows exactly what happens:
.prose[data-astro-cid-h3xj43oc] a{color:teal;text-decoration-thickness:2px}
.prose[data-astro-cid-h3xj43oc] h2{font-size:1.5rem}
The .prose wrapper must belong to this component, but any a or h2 inside it matches, whoever wrote it. This is the pattern to reach for when you render blog content, as in the post on MDX blog posts in Astro.
The is:global Directive
<style is:global> turns off scoping for the whole block. The CSS is delivered as written:
<style is:global>
/* Applies to every <h1> on any page that renders this component */
h1 {
letter-spacing: -0.02em;
}
</style>
Use this sparingly. A global block inside a component only loads on pages that use that component, which makes it easy to end up with styles that apply on some pages and not others. For site-wide rules, a stylesheet imported in your layout is clearer. That approach is covered in the post on adding global styles in Astro.
Passing a Class to a Child Component
In plain HTML, adding class="danger" to an element just works. With an Astro component it doesn't, because class is passed as a prop and the child decides what to do with it. The child has to accept the prop and apply it to its root element:
---
// src/components/Button.astro
const { class: className, ...rest } = Astro.props;
---
<button class:list={["btn", className]} {...rest}>
<slot />
</button>
<style>
.btn {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
</style>
class is a reserved word in JavaScript, so you rename it while destructuring. The class:list directive combines the component's own class with whatever the parent passed; see the post on class:list for the details.
The ...rest spread matters more than it looks. The parent styles the button like this:
---
// src/pages/index.astro
import Button from "../components/Button.astro";
---
<Button class="danger">Delete</Button>
<style>
.danger {
background: crimson;
color: white;
}
</style>
That rule compiles to .danger[data-astro-cid-lcdefpme], using the page's ID. For it to match, the button needs the page's attribute as well as its own. Astro passes that attribute to the child as a prop, and ...rest puts it on the element. With the spread in place, the built HTML is:
<button class="btn danger" data-astro-cid-lcdefpme="true" data-astro-cid-ekguhzzh>Delete</button>
When I removed ...rest from the component and rebuilt, the output became:
<button class="btn danger" data-astro-cid-ekguhzzh>Delete</button>
The danger class is there, but the parent's attribute isn't, so the crimson background never applies. If a class you pass into a component seems to do nothing, a missing ...rest is the first thing to check.
Because the child now sits partly in its parent's scope, a parent rule with a generic selector, such as button { ... }, can reach the child too. Use distinct class names in child components to avoid accidental overlap.
Scoping Strategies and Specificity
Astro supports three ways of scoping, set with the top-level scopedStyleStrategy option. The default is 'attribute'.
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
scopedStyleStrategy: "where",
});
Here's what the same .card rule compiles to with each value, taken from builds of the example above:
| Strategy | Compiled selector | Specificity added | Marker in HTML |
|---|---|---|---|
'attribute' (default) | .card[data-astro-cid-yk4hkwyg] | +1 class column | data-astro-cid-* attribute |
'class' | .card.astro-yk4hkwyg | +1 class column | astro-* class |
'where' | .card:where(.astro-yk4hkwyg) | none | astro-* class |
How to choose:
- attribute: the default, and the safest if you manipulate the
classattribute yourself in scripts, because Astro's markers live in separate attributes. - class: useful when you want element selectors inside components, like
h2, to reliably beat element selectors from a global stylesheet. - where: adds no specificity, so a scoped
h2has the same weight as a globalh2. This gives you the most control, but you have to manage the cascade yourself with specificity or cascade layers.
With 'class' and 'where', the scope marker is a class, so the class prop you pass to a child already carries it and the ...rest spread isn't required. I'd still include it, because it also forwards attributes like aria-label and id.
Scoped Styles in the Cascade
When several stylesheets target the same element, the browser looks at specificity first and order of appearance second. Astro arranges CSS in this order, lowest precedence first:
<link>tags in the head, such as files inpublic/or a CDN.- Imported stylesheets, like
import "../styles/global.css"in frontmatter, in import order. - Scoped styles from
<style>tags.
Scoped styles always come last, so when specificity is equal, they win. With the default strategy they also get one extra class's worth of specificity, which means a scoped h2 beats a global h2 from an imported file.
A more specific imported rule still wins, though:
/* src/styles/global.css */
#intro {
color: purple;
}
---
// src/components/Intro.astro
import "../styles/global.css";
---
<h1 id="intro">Welcome</h1>
<style>
h1 {
color: red;
}
</style>
The heading is purple, because an ID selector outranks h1[data-astro-cid-...]. Scoping prevents leaks; it doesn't override the normal rules of specificity.
Since Astro 6, multiple <style> and <script> tags in a component are also emitted in the order you wrote them. Astro 5 reversed them, so if you're upgrading an older project with several style blocks in one file, check that later blocks still override earlier ones as intended.
Using Variables and Dynamic Values
Scoped styles are static CSS, but they can still react to data:
- CSS custom properties: scoped rules can read any variable defined higher up the page, such as
var(--brand)from a global stylesheet. - define:vars: passes frontmatter values into a style block as custom properties. It's covered in the post on define:vars.
- class:list: toggles classes based on props, which keeps state logic in the template and styling in CSS.
These approaches keep the CSS scoped while letting each instance of a component look different.
FAQ: Scoped CSS in Astro
Yes. Every style tag in an .astro file is scoped to that component unless you add is:global or wrap part of a selector in :global(). You don't need any configuration or naming convention.
Slotted markup is written in the parent component, so it carries the parent's scope attribute rather than the child's. Use a selector like .wrapper :global(p) in the child, or style the content in the parent.
The child must accept the class prop, apply it to its root element, and spread the remaining props with ...rest so the parent's data-astro-cid attribute reaches the element. Without that attribute, the parent's scoped rule can't match.
:global() makes only part of a selector unscoped, so you can target descendants of a scoped wrapper. is:global turns off scoping for the entire style block, so every rule in it applies site-wide wherever the component is rendered.
With the default attribute strategy and the class strategy, each scoped selector gains one class-level unit of specificity. With scopedStyleStrategy set to where, the scope is wrapped in :where() and adds no specificity.
No. Astro extracts scoped styles to plain CSS at build time, either inlined in a style tag or emitted as a stylesheet in the _astro folder. No runtime is needed in the browser.
Conclusion
Astro scopes component styles by stamping each component's elements with a hashed attribute and rewriting every selector to require it. That simple mechanism is what lets you write short selectors without collisions, and it explains most of the edge cases: slotted content belongs to the parent, child components style themselves, and a class passed to a child only works when the child applies it and spreads ...rest.
When you need to break out, use :global() for targeted descendants and is:global or an imported stylesheet for genuinely site-wide rules. If specificity becomes a problem, scopedStyleStrategy lets you trade the default attribute selectors for classes or zero-specificity :where() selectors. With those pieces understood, scoped CSS becomes a quiet default you rarely have to think about.


