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
Open a component for editing (press Enter or double-click it in the Components tab).
Press E to open the Interactivity Editor.
You see two tabs: JavaScript and CSS.
Select the JavaScript tab.
Write vanilla JavaScript. Two variables are available automatically: -
el-- The component's root DOM element. -props-- An object containing all current prop values.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); // trueBoth 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
Select an element inside your component.
In the Properties panel, open the Attributes section.
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 |
|---|---|
| Item added to cart |
| Item removed from cart |
| Open a modal |
| Close a modal |
| Toggle navigation |
| 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 | 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 | Meno adds this automatically to every component root. Adding it yourself causes conflicts. |
Never query outside | Use |
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 fileHow 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 inprops.defineVars: ["count", "variant"]-- Only specified props are available.defineVarsomitted -- Theelandpropsvariables are still injected automatically when a.jsfile 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
Interactive Styles -- Combine JS class toggles with interactive style rules.
Building Components -- Structure components that work well with JS.
Styling -- Style the states that your JS controls.