Type something to search...
How to add interactivity in Astro without a framework?

How to add interactivity in Astro without a framework?

To add interactivity in Astro without a framework, put a <script> tag inside your .astro component and use standard browser APIs: find elements with querySelector(), attach behaviour with addEventListener(), and update the page by changing attributes, classes or textContent. Astro processes these scripts for you: they support TypeScript and import statements, are bundled as ES modules, and are included only once per page even if the component appears many times. For components used repeatedly, wrap the markup in a custom element so each instance manages itself. And for many common patterns, such as disclosure widgets, modals and popovers, native HTML elements like <details>, <dialog> and the popover attribute need little or no JavaScript at all.

UI frameworks are great for complex, stateful interfaces, but most interactivity on content sites is small: a menu toggle, a copy button, a like counter, a modal. Shipping React or Vue for that is a lot of JavaScript for very little behaviour. In this article you'll learn how Astro processes scripts, how to handle events, how to build reusable components with custom elements, which native HTML features replace JavaScript entirely, how to pass data and import packages, how scripts behave with view transitions, and when a framework is the better choice.

How Astro Processes Script Tags

A plain <script> in an Astro component isn't output as written. By default, Astro:

  • Supports TypeScript: every script is TypeScript, and types are stripped at build time.
  • Bundles imports: you can import local files and npm packages.
  • Outputs a module: the result becomes type="module", which runs after the HTML is parsed, so the elements you're looking for already exist.
  • Deduplicates: a component used ten times on a page still includes its script once.
  • Inlines small scripts: if the result is small enough, it's written directly into the HTML to save a request.

Astro stops processing a script if you add any attribute other than src, or the is:inline directive. Then it's output exactly as written, with no TypeScript, no imports and no deduplication.

This processing is a big part of why Astro sites ship so little JavaScript by default, as described in how Astro ships zero JavaScript.

Handling Events

Astro templates don't have framework-style event attributes like React's onClick={...} or Vue's @click. Use addEventListener() in a script instead. Here's an accessible menu toggle:

---
// src/components/MenuToggle.astro
---
<nav class="site-nav">
  <button type="button" class="menu-toggle" aria-expanded="false" aria-controls="menu">
    Menu
  </button>
  <ul id="menu" hidden>
    <li><a href="/">Home</a></li>
    <li><a href="/blog">Blog</a></li>
  </ul>
</nav>

<script>
  const toggle = document.querySelector<HTMLButtonElement>('.menu-toggle');
  const menu = document.getElementById('menu');

  toggle?.addEventListener('click', () => {
    const open = toggle.getAttribute('aria-expanded') === 'true';
    toggle.setAttribute('aria-expanded', String(!open));
    if (menu) menu.hidden = open;
  });
</script>

The state lives in the DOM itself, in aria-expanded and hidden, so there's nothing to keep in sync and screen readers get the right information for free. After one click in Chrome, aria-expanded was true and the list was no longer hidden.

Here's the script Astro produced, inlined into the page because it's so small:

<script type="module">var e=document.querySelector(`.menu-toggle`),t=document.getElementById(`menu`);e?.addEventListener(`click`,()=>{let n=e.getAttribute(`aria-expanded`)===`true`;e.setAttribute(`aria-expanded`,String(!n)),t&&(t.hidden=n)});</script>

Types removed, minified, under 200 bytes. A framework runtime would be hundreds of times larger.

Components Used More Than Once

Because scripts run once per page, querySelector() only finds the first match. If a component can appear several times, select all instances and loop over them:

<script>
  document.querySelectorAll<HTMLButtonElement>('[data-open-dialog]').forEach((button) => {
    button.addEventListener('click', () => {
      const dialog = document.getElementById(button.dataset.openDialog!);
      if (dialog instanceof HTMLDialogElement) dialog.showModal();
    });
  });
</script>

This works, but for anything with per-instance state, custom elements are cleaner.

Building Reusable Components with Custom Elements

A custom element lets you attach a class to your own HTML tag. The browser calls its connectedCallback() method once for every instance on the page, so each one can manage its own state:

---
// src/components/LikeCounter.astro
interface Props {
  start?: number;
}

const { start = 0 } = Astro.props;
---
<like-counter data-start={start}>
  <button type="button">Like</button>
  <span>{start}</span>
</like-counter>

<script>
  class LikeCounter extends HTMLElement {
    count = 0;

    connectedCallback() {
      this.count = Number(this.dataset.start ?? 0);
      const button = this.querySelector('button');
      const label = this.querySelector('span');

      button?.addEventListener('click', () => {
        this.count++;
        if (label) label.textContent = String(this.count);
      });
    }
  }

  customElements.define('like-counter', LikeCounter);
</script>
---
// src/pages/index.astro
import LikeCounter from '../components/LikeCounter.astro';
---
<LikeCounter start={4} />
<LikeCounter />

I clicked the first counter twice and the second once. They ended at 6 and 1, completely independent. Some advantages of this pattern:

  • Scoped queries: this.querySelector() only searches inside that instance, so instances never interfere.
  • Late additions work: if a <like-counter> is added to the page after load, the browser upgrades it automatically.
  • Server HTML first: the starting count is rendered on the server, so it's visible before the script runs and makes sense without JavaScript.

Custom element names must contain a hyphen. The props are passed through data-* attributes, a pattern covered in detail in passing data from Astro to client-side scripts.

Native HTML That Replaces JavaScript

Before writing a script, check whether the browser already does what you need. These features are widely supported and accessible by default.

details and summary for Disclosure

An accordion or "show more" section needs no JavaScript:

<details>
  <summary>Shipping</summary>
  <p>Free over 50 pounds.</p>
</details>

The browser handles the toggling, keyboard support and the open/closed state.

dialog for Modals

A <dialog> opened with showModal() traps focus, closes with the Escape key, and renders a backdrop. A form with method="dialog" closes it without any extra code:

---
// src/components/Modal.astro
---
<button type="button" data-open-dialog="signup">Sign up</button>

<dialog id="signup">
  <form method="dialog">
    <p>Join the newsletter.</p>
    <button value="close">Close</button>
  </form>
</dialog>

<script>
  document.querySelectorAll<HTMLButtonElement>('[data-open-dialog]').forEach((button) => {
    button.addEventListener('click', () => {
      const dialog = document.getElementById(button.dataset.openDialog!);
      if (dialog instanceof HTMLDialogElement) dialog.showModal();
    });
  });
</script>

The only script is the line that opens it. In my test, clicking "Sign up" set the dialog's open property to true, and clicking "Close" set it back to false.

popover for Tooltips and Menus

The popover attribute and a popovertarget button create a toggleable layer with no JavaScript at all:

<button type="button" popovertarget="tip">Help</button>
<div id="tip" popover>Popovers need no JavaScript.</div>

Clicking the button opened the popover (it matched :popover-open in Chrome), and clicking outside or pressing Escape closes it. Popovers are great for dropdown menus, tooltips and toasts.

Forms and CSS

Two more features are worth remembering:

  • Forms: a regular <form> posting to an Astro endpoint or action needs no client-side JavaScript to work, and you can enhance it with a script later.
  • CSS states: :hover, :focus-visible, :checked, :has() and transitions handle a lot of visual interactivity that used to need scripts.

Importing npm Packages in Scripts

Because processed scripts are bundled, you can use npm packages directly:

---
// src/components/ConfettiButton.astro
---
<button type="button" data-confetti-button>Celebrate!</button>

<script>
  import confetti from 'canvas-confetti';

  document.querySelectorAll('[data-confetti-button]').forEach((button) => {
    button.addEventListener('click', () => confetti());
  });
</script>

Install it first with npm install canvas-confetti (add @types/canvas-confetti as a dev dependency for TypeScript types). Astro bundles the package into the page's JavaScript and only loads it on pages that use the component.

You can also move longer scripts into their own files and reference them with src. A file inside src/ is processed like an inline script:

<script src="../scripts/gallery.ts"></script>

For a file in public/ or on a CDN, add is:inline so Astro leaves the tag alone:

<script is:inline src="https://example.com/widget.js"></script>

Scripts and View Transitions

If your site uses Astro's <ClientRouter /> for view transitions, navigation no longer reloads the page. Bundled module scripts run once, on the first page load, and don't run again after each navigation. Code that attaches listeners to elements on the page needs to run again when new content is swapped in. Listen for the astro:page-load event, which fires on the first load and after every navigation:

---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>My site</title>
    <ClientRouter />
  </head>
  <body>
    <slot />
    <button type="button" class="ping">Ping</button>
    <output class="pong">0</output>

    <script>
      document.addEventListener('astro:page-load', () => {
        const button = document.querySelector('.ping');
        const out = document.querySelector('.pong');

        button?.addEventListener('click', () => {
          if (out) out.textContent = String(Number(out.textContent) + 1);
        });
      });
    </script>
  </body>
</html>

I tested this by clicking the button on one page, navigating to a second page through the router, and clicking twice more. The output on the second page showed 2, which confirms the listener was attached to the new button exactly once. Custom elements also work well here, because the browser calls connectedCallback() for new elements swapped in. More on the router is in Astro view transitions.

Sharing State Between Scripts

Separate components sometimes need to react to each other, for example a "Save" button and a saved-items counter in the header. Because every processed script is a module, they can import the same store. Nano Stores is tiny and works in plain scripts:

// src/store.ts
import { atom } from 'nanostores';

export const isOpen = atom(false);
<script>
  import { isOpen } from '../store';

  isOpen.subscribe((open) => {
    document.getElementById('dialog')?.toggleAttribute('hidden', !open);
  });
</script>

The same store also works with framework islands if you add them later, as shown in sharing state between islands.

When to Reach for a Framework

Plain scripts cover a lot, but they get harder to maintain as state grows. A framework island starts to make sense when:

  • The UI is mostly state-driven: filters, multi-step forms, live search results or anything that re-renders lists as data changes.
  • You'd be writing a lot of DOM updating code: if you're building elements with createElement() in loops and keeping them in sync, a framework's templating does that for you.
  • You're reusing existing components: a React date picker or a Vue chart library is faster to adopt than to rebuild.

Even then, keep the framework to the parts that need it with a lazy directive, as explained in what the client directives mean, and leave the rest of the page as HTML and small scripts.


FAQ: Interactivity Without a Framework in Astro

Astro includes a component's script once per page, and querySelector only returns the first match. Use querySelectorAll and loop over the results, or wrap the component in a custom element so each instance runs its own connectedCallback.

You can write a plain HTML onclick="..." string attribute, but it runs as an inline handler outside Astro's script processing, and it can't reference frontmatter variables. Using addEventListener in a script tag is the recommended approach.

No. Processed scripts are output as type="module", which browsers run after the document has been parsed, so the elements in your template are available when the script runs.

Yes. Processed scripts are TypeScript by default. Scripts marked is:inline, or with attributes other than src, are output as written and must be plain JavaScript.

With ClientRouter, bundled scripts run once and aren't re-run after navigation. Put setup code inside a document listener for astro:page-load, or use custom elements, which initialise whenever they're added to the page.

Yes. Autonomous custom elements, the kind used here, are supported in all current major browsers.


Conclusion

Astro gives you a capable alternative to UI frameworks for everyday interactivity: a <script> tag that's bundled, typed and deduplicated automatically. Attach behaviour with addEventListener(), keep state in attributes like aria-expanded, and wrap reusable widgets in custom elements so each instance looks after itself. Before writing any JavaScript, check whether <details>, <dialog>, popover or plain CSS already does the job.

Pass server data in through data-* attributes, import npm packages when you need them, and use astro:page-load or custom elements if you add view transitions. Save frameworks for genuinely state-heavy interfaces. The result is pages that are interactive where it matters and still ship only a few hundred bytes of JavaScript where it doesn't.

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