A popup form with the dialog element
Modal libraries exist because building focus trapping, Escape handling and backdrops by hand is miserable. The native dialog element ships all three. This is a complete popup contact form - trigger button, modal, AJAX submit so success shows inside the dialog - in about eighty lines with no dependency.
Copy-paste HTML
<button type="button" id="open-contact">Contact us</button>
<dialog id="contact-dialog">
<form id="dialog-form" action="https://send.webforms.to/wft_your_key" method="POST">
<h2>Send us a message</h2>
<label for="pc-name">Name</label>
<input id="pc-name" name="name" type="text" required>
<label for="pc-email">Email</label>
<input id="pc-email" name="email" type="email" required>
<label for="pc-message">Message</label>
<textarea id="pc-message" name="message" rows="4" required></textarea>
<div class="dialog-actions">
<button type="button" id="close-contact">Cancel</button>
<button type="submit">Send</button>
</div>
<p id="dialog-status" role="status" aria-live="polite"></p>
</form>
</dialog>
<style>
#contact-dialog {
border: 1px solid #ddd;
border-radius: 12px;
padding: 1.5rem;
max-width: 26rem;
width: calc(100% - 2rem);
}
#contact-dialog::backdrop { background: rgb(0 0 0 / 0.45); }
#contact-dialog label { display: block; margin: 0.9rem 0 0.3rem; }
#contact-dialog input, #contact-dialog textarea {
width: 100%; box-sizing: border-box; padding: 0.55rem 0.7rem;
border: 1px solid #ccc; border-radius: 8px; font: inherit;
}
.dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.25rem; }
</style>
<script>
const dialog = document.getElementById("contact-dialog");
const dialogForm = document.getElementById("dialog-form");
const dialogStatus = document.getElementById("dialog-status");
document.getElementById("open-contact")
.addEventListener("click", () => dialog.showModal());
document.getElementById("close-contact")
.addEventListener("click", () => dialog.close());
dialogForm.addEventListener("submit", async (e) => {
e.preventDefault();
dialogStatus.textContent = "Sending...";
try {
const res = await fetch(dialogForm.action, {
method: "POST",
body: new FormData(dialogForm),
headers: { Accept: "application/json" },
});
if (res.ok) {
dialogForm.reset();
dialogStatus.textContent = "Sent - we'll get back to you soon.";
setTimeout(() => {
dialog.close();
dialogStatus.textContent = "";
}, 1600);
} else {
dialogStatus.textContent = "Something went wrong. Please try again.";
}
} catch {
dialogStatus.textContent = "Network error - check your connection.";
}
});
</script>Replace https://send.webforms.to/wft_your_key with your own endpoint from the WebForms dashboard.
Why these fields
- showModal() (not show()) is what activates the free behavior: focus moves into the dialog and is trapped, Escape closes it, and the ::backdrop pseudo-element exists. show() gives you none of that.
- The submit is AJAX so success renders inside the open dialog - a full-page redirect from inside a modal is disorienting.
- The dialog closes itself 1.6s after success: long enough to read, short enough not to need a second click.
- dialog has been supported in every major browser since March 2022. If you need older coverage, this degrades to needing a polyfill - the markup does not change.
Where to send it
Get your endpoint
Free for 300 submissions a month. No card required.
Start freePopup contact form FAQ
showModal() handles focus trapping, Escape and inertness of the page behind - the hard parts. You still owe it a visible close button and a sensible heading, both included here.
You can, and your visitors will resent it. Auto-opening contact modals convert worse than a visible button - use the trigger.