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.

AllBasicsUIDataInteractive

Todo App

A minimal todo list: add, check off and remove items.

Open in playground

Counter

Simple counter with state.

Open in playground
42
−+

Tabs

Accessible tabs component.

Open in playground
Panel 1

Fetch & Render

Fetch data and render a list. The fetch is faked, so nothing leaves the page.

Open in playground
Ada LovelaceEngineer
Grace HopperAdmiral
Alan TuringResearcher
Reload

Modal

Accessible modal dialog.

Open in playground
Open modal
Modal titleFocus is inside, the page behind is inert and Escape closes it.
Close

Form

Simple form handling with browser validation.

Open in playground
Send

Each 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.

JSRunnablePlayground
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.

JSRunnablePlayground
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.

JSRunnablePlayground
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 $().

JSRunnablePlayground
// 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 }));

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.

JSRunnablePlayground
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.

JSRunnablePlayground
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());