Examples
Examples
Small, complete mx programs you can learn from. Every one runs as-is: try it below, or open it in the playground and change it.
Fetch & Render
Fetch data and render a list. The fetch is faked, so nothing leaves the page.
Open in playgroundEach example renders into #app and needs nothing but the engine. Want bigger ones? Patterns has tables, virtual scrolling, drag and drop and a date picker. The tutorial builds a contact form step by step.
Todo App#
The list lives in the component: todos comes in through $() and every change goes out through this.$() as a new array. The text field stays uncontrolled: add() reads it from the form on submit, so typing never re-renders anything.
define('todo-app', {
$({ todos = [] }) {
let add = e => {
e.preventDefault();
let input = e.target.elements.todo;
let text = input.value.trim();
if (!text) return;
input.value = '';
this.$({ todos: [...todos, { text, done: false }] });
};
let toggle = index => this.$({ todos: todos.map((todo, i) => i === index ? { text: todo.text, done: !todo.done } : todo) });
let remove = index => this.$({ todos: todos.filter((todo, i) => i !== index) });
let left = todos.filter(todo => !todo.done).length;
return [
mx.form({ onsubmit: add },
mx.input({ name: 'todo', placeholder: 'What needs doing?', 'aria-label': 'New todo' }),
mx.button({ type: 'submit' }, 'Add')
),
mx.ul(...todos.map((todo, i) => mx.li({ class: todo.done ? 'done' : null },
mx.label(
mx.input({ type: 'checkbox', '.checked': todo.done, onchange: _ => toggle(i) }),
todo.text
),
mx.button({ onclick: _ => remove(i), 'aria-label': 'Remove ' + todo.text }, 'Remove')
))),
mx.p(left + ' of ' + todos.length + ' left')
];
}
});
document.getElementById('app').render(mx.todoApp({
todos: [{ text: 'Read the docs', done: true }, { text: 'Build something', done: false }]
}));Counter#
This is the smallest component, with one piece of state that has a default and two handlers that call this.$(). mx.clickCounter({ count: 42 }) sets the starting value from outside.
define('click-counter', {
$({ count = 0 }) {
return [
mx.button({ onclick: _ => this.$({ count: count - 1 }), 'aria-label': 'Decrement' }, '-'),
mx.output({ style: 'padding:0 12px' }, count),
mx.button({ onclick: _ => this.$({ count: count + 1 }), 'aria-label': 'Increment' }, '+')
];
}
});
document.getElementById('app').render(mx.clickCounter({ count: 42 }));Tabs#
This one component covers the WAI-ARIA tabs pattern, and role, aria-selected and a roving tabindex are just attributes in the description. Every panel is rendered and the inactive ones get hidden.
define('tab-set', {
$({ tabs = [], active = 0 }) {
let select = index => {
this.$({ active: (index + tabs.length) % tabs.length });
this.querySelector('[aria-selected="true"]').focus();
};
let step = { ArrowLeft: -1, ArrowRight: 1 };
return [
mx.div({ role: 'tablist', 'aria-label': 'Example tabs', onkeydown: e => {
if (step[e.key]) select(active + step[e.key]);
} },
...tabs.map((tab, i) => mx.button({
role: 'tab',
id: 'tab-' + i,
'aria-controls': 'panel-' + i,
'aria-selected': i === active ? 'true' : 'false',
tabindex: i === active ? '0' : '-1',
onclick: _ => select(i)
}, tab.label))
),
...tabs.map((tab, i) => mx.div({
role: 'tabpanel',
id: 'panel-' + i,
'aria-labelledby': 'tab-' + i,
tabindex: '0',
hidden: i !== active
}, tab.content))
];
}
});
document.getElementById('app').render(mx.tabSet({ tabs: [
{ label: 'Tab 1', content: 'Every panel is rendered; the inactive ones are hidden.' },
{ label: 'Tab 2', content: 'Arrow keys move between tabs, and focus follows.' },
{ label: 'Tab 3', content: 'The roles and ids are the WAI-ARIA tabs pattern.' }
] }));Fetch & Render#
Nothing goes over the network. fakeFetch returns a resolved promise with the same shape as fetch(), so the example runs anywhere. Swap it for fetch('/api/users') in a real app.
dom.userList({}) creates the element once and keeps a reference. When the data arrives, list.$({ users }) re-renders it. Loading and error states are just branches in $().
// Stands in for fetch('/api/users'): the same promise shape, no network.
let fakeFetch = url => Promise.resolve({
json: _ => Promise.resolve([
{ name: 'Ada Lovelace', role: 'Engineer' },
{ name: 'Grace Hopper', role: 'Admiral' },
{ name: 'Alan Turing', role: 'Researcher' }
])
});
define('user-list', {
$({ users, error }) {
if (error) return mx.p({ role: 'alert' }, 'Could not load users: ' + error);
if (!users) return mx.p('Loading...');
return mx.ul(...users.map(user => mx.li(mx.strong(user.name), ' - ' + user.role)));
}
});
let list = dom.userList({});
document.getElementById('app').render(list);
fakeFetch('/api/users')
.then(response => response.json())
.then(users => list.$({ users }))
.catch(error => list.$({ error: error.message }));Modal#
You don't need a modal component, because the native <dialog> element already handles focus, the inert background and Escape. dom.dialog() holds the node so the button can call showModal(), and <form method="dialog"> closes it without a handler.
let dialog = dom.dialog({ 'aria-labelledby': 'modal-title', style: 'margin:auto;max-width:360px;padding:16px' },
mx.h2({ id: 'modal-title' }, 'Modal title'),
mx.p('A native dialog: showModal() moves focus inside, makes the page behind it inert, and Escape closes it.'),
mx.form({ method: 'dialog' }, mx.button('Close'))
);
document.getElementById('app').render(
mx.button({ onclick: _ => dialog.showModal() }, 'Open modal'),
dialog
);Form#
The browser validates required and type="email" before onsubmit fires. new FormData(e.target) reads every named field at once, and the component swaps to a thank-you state.
define('signup-form', {
$({ sent }) {
if (sent) {
return [
mx.p('Thanks, ' + sent.name + '! We will write to ' + sent.email + '.'),
mx.button({ onclick: _ => this.$({ sent: null }) }, 'Send another')
];
}
return mx.form({ onsubmit: e => {
e.preventDefault();
this.$({ sent: Object.fromEntries(new FormData(e.target)) });
} },
mx.label('Name', mx.input({ name: 'name', required: true, autocomplete: 'name' })),
mx.label('Email', mx.input({ name: 'email', type: 'email', required: true, autocomplete: 'email' })),
mx.button({ type: 'submit' }, 'Send')
);
}
});
document.getElementById('app').render(mx.signupForm());