Styles & Scripts
Scoped styles are the default, proven
Section titled “Scoped styles are the default, proven”A <style> block inside a .astro file is scoped to that component automatically. Astro rewrites your selectors (adding a per-component hash) so a rule for h1 here cannot bleed into another component’s h1.
---const { accent = '#883AEA' } = Astro.props;---<div class="card">Scoped card</div><style define:vars={{ accent }}> .card { border: 2px solid var(--accent); } h1 { color: red; }</style>Used twice on the same page with different props (<ScopedCard accent="#ff0000" /> and <ScopedCard accent="#00ff00" />), real built output:
<style>.card[data-astro-cid-aoqubwov]{border:2px solid var(--accent)}h1[data-astro-cid-aoqubwov]{color:red}</style><div class="card" style="--accent: #ff0000;" data-astro-cid-aoqubwov>Scoped card</div><div class="card" style="--accent: #00ff00;" data-astro-cid-aoqubwov>Scoped card</div>Two things proven at once: the selector .card in the source becomes .card[data-astro-cid-aoqubwov] in the built CSS, and every rendered instance carries the matching data-astro-cid-aoqubwov attribute — that’s the scoping mechanism, an attribute selector plus a matching attribute, not a class rename. And even though the component was used twice, its <style> block’s CSS text appears in the page’s stylesheet once — Astro deduplicates identical component styles across instances, it does not repeat the rule per usage.
You write plain, obvious selectors and get component-local CSS — no naming conventions, no CSS-in-JS. This is the default you will use almost everywhere.
Try it: watch the hash get added in the browser. This playground loads the classic, browser-embeddable @astrojs/compiler package (see the caveat on the components/astro-syntax-deep lesson — the real Rust compiler this course’s astro check/astro build run cannot load in a browser at all). Told to use the attribute scoping strategy, it produces exactly the shape proven above — a data-astro-cid-* attribute selector, not a class rename:
A scoped .card style, compiled live
------<style> .card { border: 2px solid var(--accent); } h1 { color: red; }</style><div class="card">Scoped card</div>Click Compile: the output panel’s compiled JS carries a data-astro-cid- attribute on the <div>, and the same suffix appears in the CSS selector — the exact mechanism explained above, just produced live instead of pasted from a previous build.
Opting out with is:global, and :global(), proven
Section titled “Opting out with is:global, and :global(), proven”Sometimes you genuinely want a rule to apply site-wide — a CSS reset, base typography, a theme. Add is:global to opt a whole block out of scoping:
<style is:global> .reset-me { margin: 0; }</style><style> p { color: blue; } :global(.markdown-body a) { color: green; }</style>Real built CSS, right next to the scoped ScopedCard output above on the same page:
.reset-me{margin:0}p[data-astro-cid-yididndw]{color:#00f}.markdown-body a{color:green}.reset-me (the is:global block) has no data-astro-cid-* — it applies anywhere the class is used, site-wide. p in the second, otherwise-scoped block does get the hash — normal scoping applies rule by rule. :global(.markdown-body a) inside that same scoped block is the one rule that escapes scoping — proven side by side with its scoped sibling p, in the exact same <style> element.
Passing values into CSS with define:vars, proven
Section titled “Passing values into CSS with define:vars, proven”Frontmatter values are server-side; CSS is static text. To bridge them, define:vars injects JavaScript values as CSS custom properties, emitted as a real inline style attribute on the element — proven above: <div class="card" style="--accent: #ff0000;" ...>. It is the clean way to make a component’s colors or spacing configurable by props, with zero client JavaScript.
The <script> tag: bundled once per page, not per component instance
Section titled “The <script> tag: bundled once per page, not per component instance”A <script> in a .astro file runs in the browser — but Astro treats it as a module: it is processed, bundled, TypeScript-compiled, and its imports are resolved, just like any other JS in your build. Critically, when a component with a <script> is used multiple times on one page, the script is emitted once, not once per instance:
<div class="counter">0</div><script> console.log('COUNTER_SCRIPT_RAN'); (window as any).__counterRuns = ((window as any).__counterRuns || 0) + 1;</script>Used three times on one page (<CounterScript /><CounterScript /><CounterScript />), real built output:
<div class="counter">0</div><script type="module">console.log(`COUNTER_SCRIPT_RAN`),window.__counterRuns=(window.__counterRuns||0)+1;</script><div class="counter">0</div><div class="counter">0</div>Three <div class="counter"> elements (one per instance), but exactly one <script type="module"> tag — so COUNTER_SCRIPT_RAN logs once and __counterRuns ends at 1, not 3. A component’s script initializes shared behavior for the page, not per-instance behavior; if you need per-instance state, query document.querySelectorAll and iterate (as the data-* example below does), you don’t get three separate script executions for free.
Opting out with is:inline, proven duplicated
Section titled “Opting out with is:inline, proven duplicated”If you need the script emitted exactly as written — not bundled or processed, and not deduplicated (for an inline analytics snippet, or a script that must run before hydration) — add is:inline:
<div class="inline-marker"></div><script is:inline> console.log('INLINE_SCRIPT_RAN');</script>Used twice on the same page, real built output — contrast with the deduplicated CounterScript above:
<div class="inline-marker"></div><script> console.log('INLINE_SCRIPT_RAN');</script><div class="inline-marker"></div><script> console.log('INLINE_SCRIPT_RAN');</script>Two <script> tags, verbatim, one per instance — is:inline scripts skip both bundling and deduplication. Use them only when you specifically need the raw, untouched tag repeated exactly where it was written.
data-* attributes: passing server values into a bundled script
Section titled “data-* attributes: passing server values into a bundled script”A bundled, non-inline script can’t read frontmatter directly (it runs in the browser, long after the frontmatter ran on the server) — the standard bridge is a data-* attribute on the element the script queries:
---interface Props { userId: string }const { userId } = Astro.props;---<button class="like-btn" data-user-id={userId}>Like</button><script> document.querySelectorAll<HTMLButtonElement>('.like-btn').forEach((btn) => { const userId: string = btn.dataset.userId!; btn.addEventListener('click', () => console.log('liked by', userId)); });</script>Used on two different pages with different props (userId="u1" and userId="u2"), real built output for each:
<!-- page A --><button class="like-btn" data-user-id="u1">Like</button><script type="module">document.querySelectorAll(`.like-btn`).forEach(e=>{let t=e.dataset.userId;e.addEventListener(`click`,()=>console.log(`liked by`,t))});</script><!-- page B --><button class="like-btn" data-user-id="u2">Like</button><script type="module">document.querySelectorAll(`.like-btn`).forEach(e=>{let t=e.dataset.userId;e.addEventListener(`click`,()=>console.log(`liked by`,t))});</script>The TypeScript (querySelectorAll<HTMLButtonElement>, the : string annotation) compiles away — the shipped script is plain JS — but it still type-checks at build time, catching a typo’d dataset key or a wrong element type before it ships.
define:vars on <script>: a different mechanism than on <style>
Section titled “define:vars on <script>: a different mechanism than on <style>”define:vars also works on <script> — but it injects the values as a real JavaScript const at the top of the script, wrapped in an IIFE, not a CSS custom property:
---const apiUrl = 'https://example.com/api';---<div id="marker"></div><script define:vars={{ apiUrl }}> console.log('API_URL_IS', apiUrl);</script>Real built output:
<script>(function(){const apiUrl = "https://example.com/api";
console.log('API_URL_IS', apiUrl);})();</script>Note the shape difference from data-*: this script is emitted inline (no type="module", wrapped in its own IIFE) rather than bundled — define:vars forces per-instance inline emission on a script, the same way it forces a per-instance inline style attribute. If you need a bundled, deduplicated script (the CounterScript case above), pass data through data-* attributes instead; reach for define:vars on a script only when you specifically want that value baked into this one instance’s own inline tag.
The crucial point: a script is NOT an island
Section titled “The crucial point: a script is NOT an island”This trips up newcomers. A <script> runs in the browser, but it does not make the surrounding component an island. There is no component hydration, no framework, no re-rendering — it is just a plain script that manipulates the DOM Astro already produced.
<!-- This runs, but nothing is "hydrated". It's a script, not an island. --><div id="counter">0</div><script> let n = 0; const el = document.getElementById('counter'); document.getElementById('inc')?.addEventListener('click', () => { el.textContent = String(++n); });</script>When you want component interactivity — a React/Vue/Svelte component with state that hydrates — that is an island, created with a client directive, and it is the entire next module. Rule of thumb: reach for a <script> for small imperative DOM tasks; reach for an island when you want a stateful UI component running on the client.
A script and the ClientRouter: re-run on navigation with astro:page-load
Section titled “A script and the ClientRouter: re-run on navigation with astro:page-load”A plain <script> runs once, when the page it’s on first loads. If the site uses the ClientRouter (Astro’s opt-in client-side page transitions, astro:transitions), navigating to a new page swaps the DOM without a full page reload — so a script that only listens for DOMContentLoaded never fires again on subsequent navigations. Verified directly against the installed [email protected] source (node_modules/astro/dist/transitions/events.d.ts), the events fired are plain string names — Astro 7 removed the old TRANSITION_* exported constants:
type Events = 'astro:after-preparation' | 'astro:after-swap' | 'astro:page-load';Listen for astro:page-load to re-run your setup code after every navigation, including the first:
<script> function setup() { document.getElementById('inc')?.addEventListener('click', () => {/* ... */}); } document.addEventListener('astro:page-load', setup);</script>astro:page-load fires once on the initial load and once again after every client-side swap — a script that instead waits on DOMContentLoaded alone silently stops working after the first navigation on a ClientRouter page, because that event never fires again.
flowchart TB scr["plain script tag"] --> dom["runs in browser, manipulates existing DOM"] dom --> nohy["no component hydration"] isl["island with a client directive"] --> hyd["framework component hydrates"] hyd --> state["has its own state and re-renders"]
class:list and scoped styles: they interplay cleanly
Section titled “class:list and scoped styles: they interplay cleanly”Scoping applies to the element, regardless of how its class attribute was constructed — a scoped selector still matches a class that arrived via class:list:
---const isActive = true;---<div class:list={['card', { active: isActive }]}>x</div><style> .card { border: 1px solid; } .active { font-weight: bold; }</style>Real built output — both class:list-supplied classes get the scoping attribute, and both selectors are scoped:
<style>.card[data-astro-cid-sr4bmj7r]{border:1px solid}.active[data-astro-cid-sr4bmj7r]{font-weight:700}</style><div class="card active" data-astro-cid-sr4bmj7r>x</div>Nothing special to remember here beyond what’s already proven: class:list only decides the class attribute’s final value; scoping is a separate, always-applied data-astro-cid-* attribute plus matching CSS selectors.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a
<style>block target a selector expecting it to cascade into a child component’s markup (.parent .child-heading { ... })? Scoped styles only rewrite selectors that match elements rendered by this component’s own template — reaching into a child component needs:global(), proven above. - Is
is:inlineused on a script that was meant to be shared/bundled once per page? Proven above:is:inlineduplicates verbatim per component instance and skips import resolution — the opposite of what a “run this once” script usually wants. - Does a script rely only on
DOMContentLoaded/window.onloadon a site that uses theClientRouter? Proven above: those don’t fire again after a client-side navigation — listen forastro:page-loadtoo. - Does a script assume it can read a frontmatter value directly (
{someServerValue}typed straight into the script body) instead of passing it throughdata-*ordefine:vars? A non-inline<script>runs in the browser after the server has finished — it needs the value bridged, not referenced.
🐛 Spot the bug
---const productId = Astro.props.productId;---<button class="rate-btn">Rate this</button><script>document.querySelector('.rate-btn')?.addEventListener('click', () => { fetch('/api/rate', { method: 'POST', body: JSON.stringify({ productId }) });});</script>Show the answer
productId is a frontmatter (server-side) variable, but the <script> block runs in the browser, in an entirely separate JavaScript scope — it cannot see productId at all. This either fails to compile (a plain, non-define:vars script referencing an undeclared identifier) or, if productId happens to also exist as a global by coincidence, silently uses the wrong value. Two real fixes: <script define:vars={{ productId }}> to inject it as an inline const (proven above), or emit it as <button class="rate-btn" data-product-id={productId}> and read .dataset.productId inside a normal bundled script (also proven above) — the second is the better choice here since it keeps this script bundled/deduplicated rather than forced inline.
Mental Model
Section titled “Mental Model”A component’s <style> and <script> both default to “belongs to this component, not the page” — but they enforce that in different ways. <style> rewrites selectors and stamps a matching attribute onto every instance’s markup (proven above: the instance is marked, not the CSS deduplicated per-instance — the rule itself is written once). <script> instead deduplicates the code itself across instances on the same page (proven above: three instances, one script tag) — because unlike CSS, running the same JS three times generally isn’t what you want. Both directives that opt out (is:global, is:inline) trade that automatic, page-scoped behavior for “apply/run this exactly as I wrote it, everywhere it’s referenced.”
Trade-offs
Section titled “Trade-offs”Scoped <style> (default) |
is:global / :global() |
Bundled <script> (default) |
is:inline script |
|
|---|---|---|---|---|
| Applies to | Only this component’s own markup | Site-wide | Runs once per page, regardless of instance count | Runs once per instance, verbatim |
| Imports | n/a | n/a | Resolved and bundled | Not resolved |
| Best for | Component-local CSS (the default case) | Resets, themes, styling slotted/child content | Shared setup logic, event delegation | Analytics snippets, pre-hydration scripts |
Common Misconceptions
Section titled “Common Misconceptions”- “A component’s
<style>cascades into the components it renders as children.” Proven above: scoping only touches the current component’s own template; reaching a child requires:global(). - “Using a component with a
<style>block three times triples that CSS in the output.” Proven above: the rule is written once in the page’s stylesheet; only thedata-astro-cid-*attribute repeats, on each instance’s markup. - “Using a component with a
<script>three times runs that script three times.” Proven above: exactly one<script>tag is emitted regardless of instance count — the opposite of theis:inlinecase, which does duplicate. - “A script only needs to listen for
DOMContentLoadedto work correctly.” Proven above (from the installedastro:transitionssource): on aClientRouterpage, subsequent navigations don’t reload the document — re-run setup onastro:page-loadtoo.
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { describe, it, expect } from 'vitest';import ScopedCard from './ScopedCard.astro';
describe('scoped style hashing', () => { it('stamps a data-astro-cid-* attribute and the define:vars inline style onto the rendered element', async () => { // Container renderToString returns only this component's own markup — the // extracted <style data-astro-cid-*> stylesheet is assembled separately at // real page/build time (proven in this lesson with real built CSS output); // what the container proves directly is the per-instance scoping attribute // and the define:vars inline style landing on the element itself. const container = await AstroContainer.create(); const html = await container.renderToString(ScopedCard, { props: { accent: '#ff0000' } }); expect(html).toMatch(/data-astro-cid-[a-z0-9]+/); expect(html).toContain('<div class="card"'); expect(html).toContain('style="--accent: #ff0000;"'); });});