Tutorial: Build a Contact Form

Build a working contact form with validation and toast notifications in 6 steps. Each step adds one concept. No build tools needed.

Prerequisites

A browser and a text editor. That's it. If you haven't installed mx.js yet, see the Installation page.

Step 1: Hello mx

Create an HTML file with mx.js loaded via CDN. Render a heading and paragraph:

<!DOCTYPE html>
<html>
<head>
  <script src="https://cdn.jsdelivr.net/npm/@tltdsh/mx"></script>
</head>
<body>
  <div id="app"></div>
  <script>
    let app = document.getElementById('app');
    app.render(
        mx.h1('Contact Us'),
        mx.p('We'd love to hear from you.')
    );
  </script>
</body>
</html>

Open this file in your browser. You should see the heading. Everything from here happens in the <script> tag.

Step 2: Build the Form

Replace the app content with a form - labels, inputs, a submit handler:

let app = document.getElementById('app');

app.render(
    mx.h1('Contact Us'),
    mx.p('We'd love to hear from you.'),
    mx.form({ onsubmit: e => {
        e.preventDefault();
        alert('Submitted!');
    }},
        mx.div({ style: 'margin:12px 0' },
            mx.label({ style: 'display:block;font-size:14px;margin-bottom:4px' }, 'Name'),
            mx.input({ placeholder: 'Your name', style: 'width:100%;padding:8px' })
        ),
        mx.div({ style: 'margin:12px 0' },
            mx.label({ style: 'display:block;font-size:14px;margin-bottom:4px' }, 'Email'),
            mx.input({ type: 'email', placeholder: 'you@email.com', style: 'width:100%;padding:8px' })
        ),
        mx.div({ style: 'margin:12px 0' },
            mx.label({ style: 'display:block;font-size:14px;margin-bottom:4px' }, 'Message'),
            mx.textarea({ placeholder: 'Your message...', rows: 4, style: 'width:100%;padding:8px;resize:none' })
        ),
        mx.button({ type: 'submit', style: 'padding:8px 24px;background:#f0a828;border:none;cursor:pointer' }, 'Send')
    )
);

Key concept: mx.form(), mx.input(), mx.textarea() are just element descriptions. onsubmit is a direct event handler.

Step 3: Track Form State

Store form values in an object. Use oninput to capture changes:

let form = { name: '', email: '', message: '' };

function renderForm(app) {
    app.render(
        mx.h1('Contact Us'),
        mx.form({ onsubmit: e => {
            e.preventDefault();
            alert('Name: ' + form.name + '\nEmail: ' + form.email);
        }},
            field('Name', mx.input({
                value: form.name,
                placeholder: 'Your name',
                oninput: e => { form.name = e.target.value; }
            })),
            field('Email', mx.input({
                type: 'email',
                value: form.email,
                placeholder: 'you@email.com',
                oninput: e => { form.email = e.target.value; }
            })),
            field('Message', mx.textarea({
                value: form.message,
                placeholder: 'Your message...',
                rows: 4,
                oninput: e => { form.message = e.target.value; }
            })),
            mx.button({ type: 'submit' }, 'Send')
        )
    );
}

function field(label, input) {
    return mx.div({ style: 'margin:12px 0' },
        mx.label({ style: 'display:block;font-size:14px;margin-bottom:4px' }, label),
        input
    );
}

renderForm(document.getElementById('app'));

The field() helper reduces repetition. State lives in a plain object - no framework magic. oninput captures every keystroke.

Step 4: Add Toast Notifications

Build a toast system using dom (real elements) and setTimeout:

// Toast system - works anywhere, no component needed
let toastBox = null;

function toast(message, type) {
    if (!toastBox) {
        toastBox = dom.div({
            style: 'position:fixed;bottom:16px;right:16px;z-index:99'
        });
        document.body.append(toastBox);
    }
    let color = type === 'success' ? '#22c55e'
              : type === 'error' ? '#ef4444' : '#f0a828';
    let el = dom.div({
        style: 'padding:12px 20px;margin:4px 0;border-left:3px solid '
            + color + ';background:#1a1a2e;color:#eee;font-size:14px'
    }, message);
    toastBox.append(el);
    setTimeout(_ => {
        el.style.opacity = '0';
        el.style.transition = 'opacity 0.3s';
        setTimeout(_ => el.remove(), 300);
    }, 3000);
}

// Try it:
toast('This is a success message!', 'success');

dom.div() creates a real element - not a description. We append it to the DOM directly, then fade it out with CSS transitions. This is the difference between mx (descriptions) and dom (real nodes).

Step 5: Wire It Together

Connect the form submit to the toast. Reset the form on success:

let form = { name: '', email: '', message: '' };

function field(label, input) {
    return mx.div({ style: 'margin:12px 0' },
        mx.label({ style: 'display:block;font-size:14px;margin-bottom:4px' }, label),
        input
    );
}

function renderForm(app) {
    app.render(
        mx.h1('Contact Us'),
        mx.form({ onsubmit: e => {
            e.preventDefault();
            if (!form.name || !form.email || !form.message) {
                toast('Please fill in all fields', 'error');
                return;
            }
            toast('Message sent! We'll get back to you.', 'success');
            form = { name: '', email: '', message: '' };
            renderForm(app);
        }},
            field('Name', mx.input({
                value: form.name,
                placeholder: 'Your name',
                oninput: e => { form.name = e.target.value; }
            })),
            field('Email', mx.input({
                type: 'email',
                value: form.email,
                placeholder: 'you@email.com',
                oninput: e => { form.email = e.target.value; }
            })),
            field('Message', mx.textarea({
                value: form.message,
                placeholder: 'Your message...',
                rows: 4,
                oninput: e => { form.message = e.target.value; }
            })),
            mx.button({ type: 'submit',
                style: 'padding:8px 24px;background:#f0a828;border:none;cursor:pointer'
            }, 'Send Message')
        )
    );
}

renderForm(document.getElementById('app'));

On submit: validate, show toast, reset form, re-render. The render() call after resetting form clears the inputs.

Step 6: Add Validation

Show inline errors below each field:

let form = { name: '', email: '', message: '' };
let errors = {};

function validate() {
    errors = {};
    if (!form.name.trim()) errors.name = 'Name is required';
    if (!form.email.trim()) errors.email = 'Email is required';
    else if (!/\S+@\S+/.test(form.email)) errors.email = 'Invalid email';
    if (!form.message.trim()) errors.message = 'Message is required';
    return !Object.keys(errors).length;
}

function field(label, key, input) {
    return mx.div({ style: 'margin:12px 0' },
        mx.label({ style: 'display:block;font-size:14px;margin-bottom:4px' }, label),
        input,
        errors[key]
            ? mx.div({ style: 'color:#ef4444;font-size:12px;margin-top:4px' }, errors[key])
            : null
    );
}

function renderForm(app) {
    app.render(
        mx.h1('Contact Us'),
        mx.form({ onsubmit: e => {
            e.preventDefault();
            if (!validate()) { renderForm(app); return; }
            toast('Message sent!', 'success');
            form = { name: '', email: '', message: '' };
            errors = {};
            renderForm(app);
        }},
            field('Name', 'name', mx.input({
                value: form.name,
                placeholder: 'Your name',
                oninput: e => { form.name = e.target.value; }
            })),
            field('Email', 'email', mx.input({
                type: 'email',
                value: form.email,
                placeholder: 'you@email.com',
                oninput: e => { form.email = e.target.value; }
            })),
            field('Message', 'message', mx.textarea({
                value: form.message,
                placeholder: 'Your message...',
                rows: 4,
                oninput: e => { form.message = e.target.value; }
            })),
            mx.button({ type: 'submit',
                style: 'padding:8px 24px;background:#f0a828;border:none;cursor:pointer'
            }, 'Send Message')
        )
    );
}

renderForm(document.getElementById('app'));

The validate() function populates an errors object. Each field() checks for an error and renders it conditionally. Re-render on validation failure shows the errors; on success, everything resets.

What you built

  • A contact form with live input tracking
  • Reusable toast notification system
  • Field-level validation with error display
  • All in ~60 lines of JavaScript
  • Zero build tools, zero dependencies beyond mx.js

Next Steps