Vanilla Contact Form
A contact form that collects a name, email, and message, validates the input, and stores submissions in a JavaScript array. Zero dependencies — just open index.html in a browser.
How it works
The form listens for submit, prevents the default reload, validates that no fields are empty, and pushes the entry into a submissions array. Each submission renders into a list below the form so you can see it immediately.
contact-form/
├── index.html ← form markup + submissions list
├── style.css ← centered card layout
└── app.js ← validation, storage, renderingindex.html
Semantic form with three fields and a <ul> below it to render submissions. novalidate lets us handle validation in JS. aria-live="polite" on the status paragraph tells screen readers to announce changes.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contact Form</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main>
<h1>Contact Us</h1>
<form id="contact-form" novalidate>
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit">Send</button>
</form>
<p id="status" aria-live="polite"></p>
<section id="submissions" class="submissions" hidden>
<h2>Submissions</h2>
<ul id="submissions-list"></ul>
</section>
</main>
<script src="app.js"></script>
</body>
</html>style.css
System font stack, centered card layout, and simple focus styles. The submissions list sits below the form with a top border to separate the two sections.
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
color: #1a1a1a;
}
main {
width: 100%;
max-width: 420px;
padding: 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
margin-bottom: 1.5rem;
font-size: 1.4rem;
}
label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
input,
textarea {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
font: inherit;
}
input:focus,
textarea:focus {
outline: none;
border-color: #333;
}
button {
width: 100%;
padding: 0.6rem;
border: none;
border-radius: 4px;
background: #1a1a1a;
color: #fff;
font: inherit;
font-weight: 500;
cursor: pointer;
}
button:hover {
background: #333;
}
#status {
margin-top: 1rem;
font-size: 0.875rem;
text-align: center;
}
#status.success {
color: #16a34a;
}
#status.error {
color: #dc2626;
}
.submissions {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e5e5;
}
.submissions h2 {
font-size: 1rem;
margin-bottom: 0.75rem;
}
.submissions ul {
list-style: none;
}
.submissions li {
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
font-size: 0.85rem;
line-height: 1.4;
}
.submissions li strong {
font-weight: 600;
}app.js
FormData grabs every field by its name attribute. Object.fromEntries() converts it to a plain object. After validation, the entry gets pushed into a submissions array and rendered into the list below the form.
const form = document.getElementById('contact-form')
const statusEl = document.getElementById('status')
const submissionsSection = document.getElementById('submissions')
const submissionsList = document.getElementById('submissions-list')
const submissions = []
form.addEventListener('submit', (e) => {
e.preventDefault()
const data = Object.fromEntries(new FormData(form))
// Basic validation
if (!data.name.trim() || !data.email.trim() || !data.message.trim()) {
showStatus('All fields are required.', 'error')
return
}
submissions.push({ ...data, date: new Date().toISOString() })
form.reset()
showStatus('Message sent!', 'success')
renderSubmissions()
})
function showStatus(msg, type) {
statusEl.textContent = msg
statusEl.className = type
}
function renderSubmissions() {
submissionsSection.hidden = false
submissionsList.innerHTML = submissions
.map(
(s) =>
`<li><strong>${s.name}</strong> (${s.email})<br />${s.message}</li>`
)
.join('')
}Run it
mkdir contact-form && cd contact-form
# create the three files above
open index.htmlNo server needed — just open the HTML file in a browser. Submissions live in memory and render below the form.