Guides

JavaScript

Components add interactive behavior — dropdowns, modals, tabs, accordions, sticky headers — with plain vanilla JavaScript. That JavaScript lives in a <script> block inside the component's .astro file, right after the markup. There is no separate .js file anymore: a component is one file holding its markup, its prop block, its styles, and its script.

When you write a script in the Meno editor's JavaScript panel, it lands in that <script>. Whether you author it visually or by hand-editing the .astro, the code you write runs in a small, predictable scope with two things always available.

Where the code goes

A component's behavior is a <script> element at the bottom of its .astro file. Inside it, two variables are always in scope:

  • el — the component instance's root DOM element. Every query starts here.

  • props — an object of the component's current prop values, keyed by the names in resolveProps(Astro, {…}).

You author against el and props. When your script references props, Meno emits the script as Astro's native <script define:vars={{ … }}>, which injects those prop values into the script. Here is a complete component — markup plus script:

---
import { resolveProps, style } from 'meno-astro';

const __props = resolveProps(Astro, {
  text: { type: "string", default: "Resources" }
});
const { text, class: className } = __props;
---
<li class={style({ base: { position: "relative" } }, __props, { root: true })}>
  <button data-el="dropdown-toggle">{text}</button>
  <ul><slot /></ul>
</li>

<script define:vars={{ text }}>
  const toggle = el.querySelector('[data-el="dropdown-toggle"]');

  toggle?.addEventListener('click', () => {
    el.classList.toggle('is-open');
  });
</script>

The define:vars={{ text }} part lists exactly the props your code reads — here, text. The wrapper machinery that resolves el (which script this is, and which root element it belongs to) is generated for you; you never write it. You write top-level code that uses el and props, and it runs as soon as the component's markup is in the DOM.

If a script doesn't need any props, it carries no define:vars — it's a plain <script is:inline>. Use that for behavior that only touches el and the DOM.

Never use DOMContentLoaded

Write your code at the top level of the script so it runs immediately. Do not wrap it in a DOMContentLoaded listener.

// Wrong — this callback never runs in the editor
document.addEventListener('DOMContentLoaded', () => {
  const toggle = el.querySelector('[data-el="dropdown-toggle"]');
  toggle?.addEventListener('click', () => el.classList.toggle('is-open'));
});

// Right — top-level code runs immediately against el
const toggle = el.querySelector('[data-el="dropdown-toggle"]');
toggle?.addEventListener('click', () => el.classList.toggle('is-open'));

In a static build, DOMContentLoaded fires after the HTML loads, so the callback would run. But in the Meno editor the page is already live when your component mounts — DOMContentLoaded has long since fired, so the callback never runs and your component looks dead in the editor. Top-level code avoids the trap entirely: by the time your script runs, el is already present.

Finding child elements

To reach an element inside your component, give it a data-el attribute in the markup, then query it from the script with el.querySelector. This is the reliable way to target a child — it doesn't depend on tag names, classes, or DOM position.

Add the attribute in markup:

<button data-el="dropdown-toggle">{text}</button>
<ul data-el="menu"><slot /></ul>

Query it in the script:

const toggle = el.querySelector('[data-el="dropdown-toggle"]');
const menu = el.querySelector('[data-el="menu"]');

// Several elements sharing one data-el
const tabs = el.querySelectorAll('[data-el="tab"]');

Always query within el, not document, for elements that belong to your component. A page can render the same component many times; scoping queries to el keeps each instance working on its own DOM. document-level listeners are fine for genuinely global concerns — a click-outside check or a window scroll handler.

The state-class pattern

The cleanest way to drive a component's visual states is to toggle a class on el from JavaScript and let CSS react to it. Your script only manages a class like .is-open; the appearance is described declaratively in interactive styles, which are the second argument of style().

This is how the built-in NavDropdown component works. The markup gives the menu an interactive style keyed on the root's .is-open class:

---
import { resolveProps, style } from 'meno-astro';

const __props = resolveProps(Astro, {
  text: { type: "string", default: "Resources" },
  isOpen: { type: "boolean", default: false }
});
const { text, isOpen, class: className } = __props;
---
<li class={style({ base: { position: "relative" } }, __props, { root: true })}>
  <button data-el="dropdown-toggle" aria-expanded="false">{text}</button>
  <ul class={style({
      base: { opacity: "0", visibility: "hidden", pointerEvents: "none" }
    }, __props, {
      interactive: [
        {
          name: "on open (mobile tap)",
          prefix: "li.is-open ",
          postfix: "",
          style: { base: { opacity: "1", visibility: "visible", pointerEvents: "auto" } }
        }
      ]
    })}><slot /></ul>
</li>

<script define:vars={{ text, isOpen }}>
  const toggle = el.querySelector('[data-el="dropdown-toggle"]');

  toggle?.addEventListener('click', (e) => {
    e.preventDefault();
    const isOpen = el.classList.toggle('is-open');
    toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
  });

  // Close when clicking outside this dropdown
  document.addEventListener('click', (e) => {
    if (!el.contains(e.target)) {
      el.classList.remove('is-open');
      toggle?.setAttribute('aria-expanded', 'false');
    }
  });
</script>

The script does one job: toggle .is-open on el and keep aria-expanded in sync. The interactive style with prefix: "li.is-open " raises the menu's opacity and visibility whenever the root carries that class. JavaScript owns the state; CSS owns the look. The same shape powers the Navigation header, where the scroll handler toggles .is-scrolled and .is-hidden on el and interactive styles animate the bar.

Cross-component communication

When one component needs to react to another, dispatch a CustomEvent. The sender fires an event; any listener — on the same element, an ancestor, or document — can respond. Set bubbles: true so the event travels up the DOM and a listener higher in the tree (or on document) can catch it.

The built-in FaqItem does this: when an item opens, it announces itself so siblings can close. The script toggles its own state class, then dispatches an event:

<script define:vars={{ isOpen, question, answer }}>
  const questionEl = el.querySelector('[data-el="question"]');

  questionEl?.addEventListener('click', () => {
    const isOpening = !el.classList.contains('is-open');

    el.classList.toggle('is-open');

    if (isOpening) {
      el.dispatchEvent(new CustomEvent('faq-item-open', { bubbles: true }));
    }
  });
</script>

A surrounding FAQ list (or another FaqItem) listens for that event and reacts — for example, closing every other open item so only one answer shows at a time:

document.addEventListener('faq-item-open', (e) => {
  // Close this item if a different one just opened
  if (e.target !== el) {
    el.classList.remove('is-open');
  }
});

You can carry data on the event with a detail object. The Modal component listens for an open request and reads details from it:

// Trigger component: ask a modal to open
el.addEventListener('click', () => {
  el.dispatchEvent(new CustomEvent('open-modal', {
    bubbles: true,
    detail: { videoUrl: props.videoUrl }
  }));
});

// Modal component: react to the request
el.addEventListener('open-modal', (e) => {
  el.classList.add('is-open');
  console.log(e.detail.videoUrl);
});

A consistent event-naming scheme keeps things untangled. Pick clear names like open-modal, faq-item-open, or a namespace:action form like cart:add so events from different features never collide.

Rules to remember

Rule

Why

Write top-level code, never DOMContentLoaded

The event has already fired in the editor; the callback never runs

Query inside el, not document

Each instance only touches its own DOM; document is for global listeners

Use data-el to target children

Stable selection that doesn't break when markup changes

Toggle classes on el; style with interactive styles

JavaScript owns state, CSS owns appearance

Communicate with CustomEvent + bubbles: true

Lets components react without direct references

Vanilla JavaScript only

No React, JSX, or hooks — Meno handles rendering


For the styles your scripts switch between, see interactive styles. For the rest of the component file — the prop block, slots, and metadata — see building components.

Building the future of digital experiences, one website at a time

Product

Resources

Comparisons

© 2026 Meno. All rights reserved.