Skip to content

Styles & Scripts

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.

src/components/ScopedCard.astro
---
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:

src/components/GlobalBit.astro
<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:

src/components/CounterScript.astro
<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:

src/components/InlineScript.astro
<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:

src/components/DataAttrScript.astro
---
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:

src/components/ScriptDefineVars.astro
---
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"]
Script versus island — two different things

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:

src/components/ClassListScoped.astro
---
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.

  • 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:inline used on a script that was meant to be shared/bundled once per page? Proven above: is:inline duplicates 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.onload on a site that uses the ClientRouter? Proven above: those don’t fire again after a client-side navigation — listen for astro:page-load too.
  • Does a script assume it can read a frontmatter value directly ({someServerValue} typed straight into the script body) instead of passing it through data-* or define:vars? A non-inline <script> runs in the browser after the server has finished — it needs the value bridged, not referenced.

🐛 Spot the bug

src/components/RatingWidget.astro
---
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.

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.”

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
  • “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 the data-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 the is:inline case, which does duplicate.
  • “A script only needs to listen for DOMContentLoaded to work correctly.” Proven above (from the installed astro:transitions source): on a ClientRouter page, subsequent navigations don’t reload the document — re-run setup on astro:page-load too.
src/components/ScopedCard.test.ts
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;"');
});
});
By default, what is the scope of a <style> block in a .astro component?
How do you pass a frontmatter value into a component's CSS?
A plain <script> tag in a .astro component...
What does adding is:inline to a script do?
Used three times on one page, proven with a real build, how many <script> tags and console.log calls result?
---
// src/components/CounterScript.astro
---
<div class="counter">0</div>
<script>
  console.log('RAN');
</script>
On a page using the ClientRouter (astro:transitions), a script only runs its setup inside a DOMContentLoaded listener. What is the proven problem?