Welcome to Vanilla Breeze
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
← All tutorials · ~10 minutes · Layer focus: forms & validation
Build an accessible contact form with native HTML validation, helpful error messages, and a submit-success toast — in that order. Every layer works without the next, and the whole thing gracefully degrades if JS never loads.
Before any framework, HTML forms did required fields, email validation, and error messages. Start there.
<form action="/contact" method="post"> <h1>Get in touch</h1> <p>We reply within one business day.</p> <label for="name">Full name</label> <input id="name" name="name" type="text" required autocomplete="name"/> <label for="email">Email</label> <input id="email" name="email" type="email" required autocomplete="email"/> <label for="topic">Topic</label> <select id="topic" name="topic" required> <option value="">Pick one…</option> <option>Sales question</option> <option>Technical support</option> <option>Press & media</option> </select> <label for="message">Message</label> <textarea id="message" name="message" rows="4" required minlength="20"></textarea> <button type="submit">Send message</button></form>
This form submits to a server, validates on the client, and shows browser-default error tooltips. No CSS, no JS.
data-*A form is a vertical stack. Buttons belong in a cluster. The whole thing gets a readable width via data-layout-max="narrow". No wrappers added.
<main data-layout="center" data-layout-max="narrow"> <form data-layout="stack" data-layout-gap="m" aria-labelledby="contact-title"> <header data-layout="stack" data-layout-gap="xs"> <h1 id="contact-title">Get in touch</h1> <p>We reply within one business day.</p> </header> <!-- labels and inputs as before --> <footer data-layout="cluster" data-layout-gap="s"> <button type="submit">Send message</button> <button type="reset" class="ghost">Clear</button> </footer> </form></main>
Notice the <footer> inside the form: it's semantically fine and we use data-layout="cluster" to keep the buttons on one line, wrapping on narrow screens.
<form-field>The <form-field> custom element wraps a label, control, and message slot. You get consistent spacing, accessible error messaging, and a hook for live validation feedback — without rewriting your inputs.
<form-field> <label for="name">Full name</label> <input id="name" name="name" type="text" required autocomplete="name" data-message-valuemissing="Please tell us your name"/></form-field> <form-field> <label for="email">Email</label> <input id="email" name="email" type="email" required autocomplete="email" data-message-valuemissing="We need an email to reply" data-message-typemismatch="That doesn't look like a valid email"/></form-field> <form-field> <label for="message">Message</label> <textarea id="message" name="message" rows="4" required minlength="20" data-message-valuemissing="A short message helps us route this" data-message-tooshort="A few more words, please"></textarea></form-field>
Every data-message-* attribute maps to a ValidityState flag. The form still uses native HTML validation — <form-field> just surfaces the right message at the right time, in plain text, next to the right input.
<toast-msg>When the form submits, the server either renders a thank-you page or returns a 200. For the JS-enhanced path, pop a toast so the reader knows it worked — without navigating.
<toast-msg id="contact-toast" position="top-end" duration="4000"></toast-msg> <script type="module"> const form = document.querySelector('form'); const toast = document.querySelector('#contact-toast'); form.addEventListener('submit', async (e) => { e.preventDefault(); const res = await fetch('/contact', { method: 'POST', body: new FormData(form) }); if (res.ok) { toast.show({ variant: 'success', title: 'Message sent', description: 'We reply within one business day.' }); form.reset(); } else { toast.show({ variant: 'error', title: 'Something went wrong', description: 'Please try again in a moment.' }); } });</script>
If JavaScript fails to load, the form still submits to /contact and the server takes over. The toast is a progressive enhancement, not a dependency.
data-layout="stack" on a <form> gives vertical rhythm with one attribute.<form-field> turns native ValidityState messages into readable prose via data-message-*.<toast-msg> surfaces success without navigating — and vanishes cleanly when JS is off.