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
- Components - extract the form into a reusable
define() component - Architecture - add routing, multiple pages, API integration
- Patterns - tables, virtual scroll, drag & drop