Guides

JavaScript

Components in Meno can include vanilla JavaScript for interactive behavior -- dropdowns, modals, tabs, accordions, form validation, and more. JavaScript is written in a separate file alongside the component definition and runs automatically when the component mounts.


Adding JavaScript to a Component

  1. Open a component for editing (press Enter or double-click it in the Components tab).

  2. Press E to open the Interactivity Editor.

  3. You see two tabs: JavaScript and CSS.

  4. Select the JavaScript tab.

  5. Write vanilla JavaScript. Two variables are available automatically: - el -- The component's root DOM element. - props -- An object containing all current prop values.

  6. Close the editor. Your JavaScript is saved with the component.

The Basics

el -- The Component Root

el is a reference to the outermost DOM element of your component instance. Use it as your starting point for all DOM queries:

// Find elements inside this component
const menu = el.querySelector('[data-el="menu"]');
const buttons = el.querySelectorAll('[data-action="tab"]');

// Toggle classes on the component root
el.classList.toggle('is-open');

// Read data attributes
const variant = el.dataset.variant;

props -- Component Prop Values

props contains the current values of all props defined in the component interface:

// If the component has props: title (string), count (number), isVisible (boolean)
console.log(props.title);     // "Hello World"
console.log(props.count);     // 5
console.log(props.isVisible); // true

Both el and props are injected automatically when the component has a .js file -- you do not need to import or initialize them.

Finding Elements

Use data-el attributes to mark elements you need to reference from JavaScript.

Adding data-el in the Editor

  1. Select an element inside your component.

  2. In the Properties panel, open the Attributes section.

  3. Add an attribute: name = data-el, value = a descriptive name (e.g., "trigger", "menu", "counter").

Querying Elements

// Single element
const trigger = el.querySelector('[data-el="trigger"]');
const menu = el.querySelector('[data-el="menu"]');
const output = el.querySelector('[data-el="output"]');

// Multiple elements with the same data-el
const tabs = el.querySelectorAll('[data-el="tab"]');

You can also use data-action for clickable elements:

const submitBtn = el.querySelector('[data-action="submit"]');
const closeBtn = el.querySelector('[data-action="close"]');

Important: Always query within el, not document. This ensures your component only affects its own DOM and does not interfere with other instances on the page.

Event Listeners

Attach event listeners to elements found with el.querySelector:

const trigger = el.querySelector('[data-el="trigger"]');

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

// Keyboard support
trigger?.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    el.classList.toggle('is-open');
  }
});

Click Outside Pattern

A common pattern for dropdowns and modals -- close when clicking outside:

document.addEventListener('click', (e) => {
  if (!el.contains(e.target)) {
    el.classList.remove('is-open');
  }
});

Escape Key Pattern

Close on Escape key:

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    el.classList.remove('is-open');
  }
});

Component Communication

Components can communicate with each other using CustomEvent. This is the recommended pattern for cross-component interaction.

Dispatching an Event

// In a "AddToCart" button component
const button = el.querySelector('[data-el="button"]');

button?.addEventListener('click', () => {
  const event = new CustomEvent('cart:add', {
    bubbles: true,
    detail: {
      productId: props.productId,
      quantity: 1
    }
  });
  el.dispatchEvent(event);
});

Listening for an Event

// In a "CartCount" badge component
document.addEventListener('cart:add', (e) => {
  const counter = el.querySelector('[data-el="count"]');
  if (counter) {
    const current = parseInt(counter.textContent || '0');
    counter.textContent = String(current + e.detail.quantity);
  }
});

Event Naming Convention

Use a namespace:action pattern for custom events to avoid collisions:

Event Name

Purpose

cart:add

Item added to cart

cart:remove

Item removed from cart

modal:open

Open a modal

modal:close

Close a modal

nav:toggle

Toggle navigation

theme:change

Theme switched

Detecting Editor vs. Production

Use the {{isEditorMode}} template variable to run code only in the editor or only in production:

const isEditor = '{{isEditorMode}}' === 'true';

if (!isEditor) {
  // Only in production: initialize analytics, start animations, etc.
  startScrollAnimations();
}

if (isEditor) {
  // Only in editor: show placeholder content, skip heavy operations
  el.querySelector('[data-el="video"]')?.setAttribute('src', '');
}

Common Patterns

Accordion

const triggers = el.querySelectorAll('[data-action="toggle"]');

triggers.forEach((trigger) => {
  trigger.addEventListener('click', () => {
    const panel = trigger.nextElementSibling;
    const isOpen = trigger.getAttribute('aria-expanded') === 'true';

    trigger.setAttribute('aria-expanded', String(!isOpen));
    panel.hidden = isOpen;
  });
});

Tabs

const tabs = el.querySelectorAll('[data-el="tab"]');
const panels = el.querySelectorAll('[data-el="panel"]');

tabs.forEach((tab, index) => {
  tab.addEventListener('click', () => {
    // Deactivate all
    tabs.forEach((t) => t.classList.remove('is-active'));
    panels.forEach((p) => (p.hidden = true));

    // Activate selected
    tab.classList.add('is-active');
    if (panels[index]) panels[index].hidden = false;
  });
});

Scroll-triggered Animation

const isEditor = '{{isEditorMode}}' === 'true';

if (!isEditor) {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add('is-visible');
          observer.unobserve(entry.target);
        }
      });
    },
    { threshold: 0.1 }
  );

  const animatedElements = el.querySelectorAll('[data-animate]');
  animatedElements.forEach((elem) => observer.observe(elem));
}

Form Validation

const form = el.querySelector('[data-el="form"]');
const emailInput = el.querySelector('[data-el="email"]');
const errorMsg = el.querySelector('[data-el="error"]');

form?.addEventListener('submit', (e) => {
  e.preventDefault();

  const email = emailInput?.value || '';
  if (!email.includes('@')) {
    errorMsg.textContent = 'Please enter a valid email.';
    errorMsg.hidden = false;
    return;
  }

  errorMsg.hidden = true;
  // Process form...
});

Things to Avoid

Important: Follow these rules to ensure your JavaScript works correctly in both the editor and production.

Rule

Why

Never use DOMContentLoaded

The event has already fired by the time component JS runs. Your code will never execute.

Never use React or JSX

Component JS is vanilla JavaScript only. Meno handles rendering.

Never add data-component attribute manually

Meno adds this automatically to every component root. Adding it yourself causes conflicts.

Never query outside el for component-internal elements

Use el.querySelector(), not document.querySelector(), for elements inside your component. Querying document is fine for global listeners.


Under the Hood

File Structure

JavaScript for a component lives in a .js file alongside the .json definition:

components/
  Dropdown.json    # Component definition (structure + interface)
  Dropdown.js      # Interactive behavior
  Tabs.json
  Tabs.js
  Button.json      # No JS needed -- no .js file

How defineVars Works

When a component has a .js file, Meno automatically wraps it and injects el and props. In the component JSON, the defineVars property controls this:

{
  "component": {
    "structure": { "..." : "..." },
    "interface": { "..." : "..." },
    "defineVars": true
  }
}
  • defineVars: true -- All interface props are available in props.

  • defineVars: ["count", "variant"] -- Only specified props are available.

  • defineVars omitted -- The el and props variables are still injected automatically when a .js file exists.

Example: Complete Component JS

Given a Counter component with data-el="count", data-action="decrement", and data-action="increment" elements and an initialCount number prop:

Counter.js:

const countDisplay = el.querySelector('[data-el="count"]');
const decrementBtn = el.querySelector('[data-action="decrement"]');
const incrementBtn = el.querySelector('[data-action="increment"]');

let count = props.initialCount || 0;

function updateDisplay() {
  if (countDisplay) {
    countDisplay.textContent = String(count);
  }
}

decrementBtn?.addEventListener('click', () => {
  count--;
  updateDisplay();
});

incrementBtn?.addEventListener('click', () => {
  count++;
  updateDisplay();

  // Notify other components
  el.dispatchEvent(
    new CustomEvent('counter:change', {
      bubbles: true,
      detail: { count }
    })
  );
});

CSS Tab

The Interactivity Editor also has a CSS tab for component-scoped styles. CSS written here is rendered in a <style> tag in the page <head>. This is useful for animations, complex selectors, or styles that cannot be expressed through the visual editor.


Next Steps

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

© 2026 Company. All rights reserved.