# Client-Side Scripts

Course: [Astro](https://www.devtutorials.ai/learn/astro) · Section: Build Pages
Source: https://www.devtutorials.ai/learn/astro/build-pages/client-side-scripts

> Script tags in Astro components, how scripts are bundled and run once, importing npm packages, TypeScript in scripts, is:inline and define:vars, event handling in repeated components, data attributes, web components, scripts from public/ and Partytown.

## Client-Side Scripts

- So far, every page has been plain HTML. Nothing reacts to clicks.
- A **client-side script** is JavaScript that runs in the visitor's browser.
- In Astro, you add one with a normal `<script>` tag in a component.
- In this lesson, you will add small interactive features to Asha's site without any UI framework.

**Simple lesson:** a `<script>` tag adds JavaScript to the page. Use it only for parts that react.

### A Real-World Example

- Asha's readers often study at night. They ask for a dark theme.
- The theme must switch when a reader clicks a button.
- The page must remember the choice for the next visit.
- That needs JavaScript in the browser, so Asha adds a small `<script>` to her `ThemeToggle` component.

Press **▶ Run**, then click the button. Open **HTML sent to browser** to find the script.

<script>
    const button = document.getElementById("theme-toggle") as HTMLButtonElement;

    function apply(theme: string) {
      document.documentElement.dataset.theme = theme;
      button.textContent = theme === "dark" ? "☀️ Light mode" : "🌙 Dark mode";
      localStorage.setItem("theme", theme);
    }

    apply(localStorage.getItem("theme") ?? "light");

    button.addEventListener("click", () => {
      const now = document.documentElement.dataset.theme;
      apply(now === "dark" ? "light" : "dark");
    });
</script>
`,
    "src/pages/index.astro": `---
---

<h1>Asha's Dev Journal</h1>
<p>Reading at night is easier on dark pages.</p>

<style is:global>
    [data-theme="dark"] body {
      background: #0f172a;
      color: #e2e8f0;
    }
</style>
`,
  }}
/>

What happened:

- The heading and paragraph are plain HTML. They need no JavaScript.
- Only the button got a script.
- The script sets `data-theme` on the `<html>` tag. The global CSS reacts to it.
- `localStorage` remembers the choice. On a real site, it lasts between visits.

### What You Will Learn

In this lesson, you will learn:

1. How Astro processes a `<script>` tag.
2. Why a script runs only once, even in a repeated component.
3. How to import npm packages and write TypeScript in scripts.
4. When to use `is:inline` and `define:vars`.
5. How to handle events for many copies of a component.
6. How to pass data from the component script to the browser.
7. How web components (custom elements) fit in.
8. How to load scripts from `public/` and third parties.

## Script Tags

### script in Astro Components

- Write `<script>` anywhere in a component. Most people put it at the end.
- It runs in the **browser**, after the page loads.
- It cannot see the variables from the component script. That code ran at build time.

### Bundling Scripts

- **Processed script** = a plain `<script>` tag. Astro processes it at build time.
- Astro:
  - removes TypeScript types,
  - joins imports into one file,
  - shrinks the code,
  - adds `type="module"` to the tag.
- `type="module"` scripts run after the HTML is ready, so the elements already exist.

### Importing npm Packages in Scripts

- A processed script can import packages you installed with npm:

```astro
<script>
  import confetti from "canvas-confetti";
  document.querySelector("#party")?.addEventListener("click", () => confetti());
</script>
```

- Astro bundles the package into your script. The visitor downloads only what you used.

### Scripts Run Only Once

- If a component appears 10 times on a page, its `<script>` is added **once**, not 10 times.
- So the script must find **every** copy of the component itself, with `querySelectorAll()`.
- You will do this in **Handling Events in Repeated Components** below.

This is true only for processed scripts. A script with `is:inline` or `define:vars` is repeated once for every copy of the component.

<figure className="ast-fig ngc-fig">
<svg viewBox="0 0 640 200" role="img" aria-labelledby="fig-once" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-once">Animation: three LikeButton copies on a page share one script. The script reaches out to all three buttons.</title>
  <defs><marker id="on-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" style={{ fill: "var(--ngc-slate)" }} /></marker></defs>
  <rect x="20" y="20" width="300" height="160" rx="10" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)", strokeWidth: 1.5 }} />
  <text x="170" y="42" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 12 }}>The page</text>
  <g style={{ fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>
    <rect x="50" y="56" width="120" height="30" rx="6" style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)" }} /><text x="110" y="76" textAnchor="middle" style={{ fill: "var(--ngc-orange)" }}>♥ Like</text>
    <rect x="50" y="96" width="120" height="30" rx="6" style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)" }} /><text x="110" y="116" textAnchor="middle" style={{ fill: "var(--ngc-orange)" }}>♥ Like</text>
    <rect x="50" y="136" width="120" height="30" rx="6" style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)" }} /><text x="110" y="156" textAnchor="middle" style={{ fill: "var(--ngc-orange)" }}>♥ Like</text>
  </g>
  <rect x="420" y="76" width="190" height="56" rx="10" style={{ fill: "var(--ngc-purple)" }}><animate attributeName="opacity" values="1;0.7;1" dur="1.5s" repeatCount="indefinite" /></rect>
  <text x="515" y="100" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>1 script</text>
  <text x="515" y="118" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--mono)", fontSize: 11 }}>querySelectorAll()</text>
  <g style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }}>
    <path d="M420 104 L172 71" markerEnd="url(#on-arrow)" />
    <path d="M420 104 L172 111" markerEnd="url(#on-arrow)" />
    <path d="M420 104 L172 151" markerEnd="url(#on-arrow)" />
  </g>
  <circle r="5" style={{ fill: "var(--ngc-orange)" }}><animateMotion dur="2s" repeatCount="indefinite" path="M420 104 L172 71" /></circle>
  <circle r="5" style={{ fill: "var(--ngc-orange)" }}><animateMotion dur="2s" begin="0.3s" repeatCount="indefinite" path="M420 104 L172 111" /></circle>
  <circle r="5" style={{ fill: "var(--ngc-orange)" }}><animateMotion dur="2s" begin="0.6s" repeatCount="indefinite" path="M420 104 L172 151" /></circle>
</svg>
<figcaption>Three copies of the component, one script. The script must find all three.</figcaption>
</figure>

### [JS] Writing Scripts in JavaScript

- Write normal browser JavaScript inside the tag.
- `document.querySelector()`, `addEventListener()` and `localStorage` all work as usual.

### [TS] Writing Scripts in TypeScript

- A processed `<script>` can hold TypeScript. Astro removes the types.
- Tell TypeScript what an element is: `document.querySelector<HTMLButtonElement>("#save")` or `as HTMLButtonElement`.
- `astro check` checks scripts too.

**Simple lesson:** a plain `<script>` is bundled, typed, shrunk and added once per page.

## Inline Scripts

### is:inline

- `<script is:inline>` tells Astro: "do not process this script".
- Astro puts it into the HTML exactly as written:
  - no bundling, no TypeScript, no imports,
  - and it is repeated for every copy of the component.
- Use it for small scripts that must run **before** the page is drawn, such as setting the theme early.

### define:vars with Scripts

- `define:vars` passes values from the component script into a browser script.
- `<script define:vars={{ greeting }}>` lets the script use `greeting`.
- It makes the script **inline**: no bundling, and it runs once per component copy.

### When to Skip Processing

Use `is:inline` when:

- the script must run as early as possible, before CSS and images,
- the script is a small third-party snippet that must stay exactly as given.

Otherwise, use a plain `<script>`. You get bundling, TypeScript and smaller files.

Press **▶ Run**. Compare the three scripts in **HTML sent to browser** and in the **Terminal**.

<script>
    // Processed: bundled, TypeScript allowed, runs once.
    const total: number = 2 + 3;
    console.log("Processed script, total =", total);
</script>

<script is:inline>
    // Inline: copied as it is. No TypeScript here.
    console.log("Inline script runs as written");
</script>

<script define:vars={{ greeting }}>
    // define:vars: gets a value from the build.
    console.log(greeting);
</script>
`,
  }}
/>

- The processed script became `<script type="module">`, with the types removed.
- The inline script is unchanged.
- The `define:vars` script got `greeting` from the build.

**Simple lesson:** use a plain `<script>` by default. Use `is:inline` only for early or third-party snippets.

## Event Handling

### Selecting Elements

- `document.querySelector("#id")` finds the first match.
- `document.querySelectorAll(".class")` finds every match.
- Give the elements you need a clear `id`, `class` or `data-` attribute.

### Adding Event Listeners

- `element.addEventListener("click", () => { ... })` runs code on each click.
- Other common events: `"input"`, `"submit"`, `"change"`, `"keydown"`.

### Handling Events in Repeated Components

- The script runs once, so loop over every copy.
- Inside each copy, find that copy's own elements. Then each button changes only its own counter.

<script>
    const buttons = document.querySelectorAll<HTMLButtonElement>(".like");

    buttons.forEach((button) => {
      const count = button.querySelector("span")!;
      let likes = 0;
      button.addEventListener("click", () => {
        likes = likes + 1;
        count.textContent = String(likes);
      });
    });
</script>
`,
    "src/pages/index.astro": `---
---

<h2>Why I chose Astro</h2>
<h2>My first component</h2>
<h2>Props made simple</h2>
`,
  }}
/>

- Click each button. Each one counts on its own.
- **HTML sent to browser** has three buttons but only one script.

**Simple lesson:** loop with `querySelectorAll()`, and work inside each copy.

## Passing Data to Scripts

### Data Attributes

- **Data attribute** = an attribute that starts with `data-`, such as `data-post-id="42"`.
- You set it from the component script: `<button data-message={message}>`.
- It is the safe, simple bridge from build time to the browser.

### Reading Data in Scripts

- In the script, read it with `element.dataset`.
- `data-post-id` becomes `dataset.postId`. Dashes turn into camelCase.
- Every value arrives as a string. Use `Number()` for numbers.

    Share "{title}"
</button>

<script>
    document.querySelectorAll<HTMLButtonElement>(".share").forEach((button) => {
      button.addEventListener("click", () => {
        const title = button.dataset.title;
        const likes = Number(button.dataset.likes);
        const out = document.getElementById("out")!;
        out.textContent = "Sharing: " + title + " (" + (likes + 1) + " likes after yours)";
      });
    });
</script>
`,
    "src/pages/index.astro": `---
---

<p id="out">Click a button.</p>
`,
  }}
/>

**Simple lesson:** put values in `data-` attributes at build time. Read them with `dataset` in the browser.

## Web Components

### Custom Elements in Astro Components

- **Web component** = your own HTML tag, such as `<reading-timer>`, with its own behaviour. Browsers support them without any framework.
- **Custom element** = the JavaScript class behind the tag. You register it with `customElements.define()`.
- The tag name must contain a dash: `reading-timer`, not `readingtimer`.

### connectedCallback

- `connectedCallback()` runs each time a copy of the tag appears on the page.
- So each copy sets itself up. There is no need for `querySelectorAll()`.

### Passing Data to Custom Elements

- Use `data-` attributes again. Inside the class, read `this.dataset`.

<script>
    class ReadingTime extends HTMLElement {
      connectedCallback() {
        const words = Number(this.dataset.words);
        const minutes = Math.max(1, Math.round(words / 200));
        this.textContent = "⏱ " + minutes + " min read";
      }
    }

    customElements.define("reading-time", ReadingTime);
</script>
`,
    "src/pages/index.astro": `---
---

<h2>A short note</h2>
<h2>A long guide</h2>
`,
  }}
/>

**Simple lesson:** a custom element sets up each copy by itself in `connectedCallback()`.

## External Scripts

### Loading Scripts from public/

- A script file in `public/` is not processed.
- Load it with `<script is:inline src="/analytics.js"></script>`.
- Use this for files that must keep their exact name and content.

### Third-Party Scripts with Partytown

- **Third-party script** = code from another company, such as analytics or ads.
- These scripts can slow your page, because they run on the browser's main thread.
- **Partytown** = an optional integration that moves them to a **web worker**, a background thread.
- Add it with `npx astro add partytown`. Then mark the script: `<script type="text/partytown" src="...">`.

Every script costs the visitor download and run time. Before adding one, ask: "Does this part really need to react in the browser?"

When many parts of a page need complex state, a UI framework island may be easier than plain scripts. You will learn that in **Astro Islands**.

**Simple lesson:** load `public/` scripts with `is:inline src`. Move heavy third-party scripts off the main thread with Partytown.

## TL;DR

1. **Client-side script** = JavaScript that runs in the browser. Add it with `<script>`.
2. **Processed script** = plain `<script>`. Bundled, TypeScript allowed, npm imports allowed, `type="module"`, added once per page.
3. **is:inline** = not processed. Copied as written, once per component copy.
4. **define:vars** on a script = passes build values in. Makes the script inline.
5. **Repeated components** = `querySelectorAll()` and work inside each copy.
6. **Data attribute** = `data-x={value}` at build time, `element.dataset.x` in the browser.
7. **Web component** = `customElements.define("my-tag", Class)`. `connectedCallback()` runs for each copy.
8. **public/ scripts** = `<script is:inline src="/file.js">`.
9. **Partytown** = runs third-party scripts in a web worker.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Using a component-script variable in `<script>` | Both scripts share variables | Pass it with a `data-` attribute or `define:vars` | `ReferenceError` in the browser |
| `querySelector` in a repeated component | The script runs once per copy | Loop with `querySelectorAll()` | Only the first copy works |
| TypeScript in `is:inline` | Every script is processed | Only plain `<script>` tags are processed | Syntax error in the browser |
| `import` in `is:inline` | Imports always work | Use a plain `<script>` for imports | The browser cannot find the package |
| Reading `dataset.likes` as a number | Data attributes keep their type | Use `Number(...)` | `"12" + 1` becomes `"121"` |
| Custom element name without a dash | Any name works | Use a dash: `reading-time` | `customElements.define` throws an error |

</div>

## Check Yourself

## Exercises

## What to Remember

- A plain `<script>` runs in the browser. Astro bundles it and adds it once per page.
- Loop with `querySelectorAll()` for repeated components.
- Pass build-time values with `data-` attributes or `define:vars`.
- Use `is:inline` only for early or third-party snippets.
- Web components set up each copy in `connectedCallback()`.

**Checkpoint:** Asha's site now has a layout, a menu, many pages, styles and a theme toggle. That is a complete static site.

Next: **Markdown and MDX**, where Asha writes her posts as text files instead of code.
