<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modal Form — Formfield</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
serif: ["DM Serif Display", "serif"],
sans: ["DM Sans", "sans-serif"],
},
colors: {
ink: "#111110",
"ink-light": "#1e1d1b",
"off-white": "#f5f4f1",
"warm-muted": "#888580",
"warm-border": "#2a2927",
"warm-mid": "#e8e7e4",
"warm-bg": "#f5f4f1",
err: "#c0392b",
"err-bg": "#fdf2f0",
ok: "#2d6a4f",
"ok-bg": "#f0faf5",
},
boxShadow: {
focus: "0 0 0 3px rgba(17,17,16,0.07)",
"focus-err": "0 0 0 3px rgba(192,57,43,0.09)",
"focus-ok": "0 0 0 3px rgba(45,106,79,0.09)",
modal:
"0 8px 40px rgba(17,17,16,0.12), 0 2px 8px rgba(17,17,16,0.06)",
},
keyframes: {
spin: { to: { transform: "rotate(360deg)" } },
},
animation: {
spin: "spin 0.7s linear infinite",
},
},
},
};
</script>
<style>
/* Unavoidable: modal open/close transition (transform + opacity together) */
#modal-panel {
transition:
opacity 0.25s ease,
transform 0.28s cubic-bezier(0.34, 1.3, 0.64, 1);
}
#modal-overlay {
transition: opacity 0.25s ease;
}
/* Strength bar width transition */
#strength-fill {
transition:
width 0.3s ease,
background-color 0.3s ease;
}
/* Custom checkbox checked mark — Tailwind can't do ::after pseudo */
.form-checkbox:checked::after {
content: "";
position: absolute;
top: 2px;
left: 5px;
width: 4px;
height: 7px;
border: 1.5px solid #f5f4f1;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
/* Scrollbar */
#modal-panel::-webkit-scrollbar {
width: 4px;
}
#modal-panel::-webkit-scrollbar-track {
background: transparent;
}
#modal-panel::-webkit-scrollbar-thumb {
background: #e8e7e4;
border-radius: 4px;
}
#modal-panel {
scrollbar-width: thin;
scrollbar-color: #e8e7e4 transparent;
}
</style>
</head>
<body class="font-sans bg-warm-bg text-ink min-h-screen">
<!-- NAVBAR -->
<nav
class="sticky top-0 z-[100] border-b border-warm-mid"
style="background: rgba(245, 244, 241, 0.92); backdrop-filter: blur(12px)"
>
<div class="max-w-[1280px] mx-auto px-6 lg:px-10">
<div class="flex items-center justify-between h-16">
<a href="#" class="flex items-center gap-2 no-underline">
<div
class="w-7 h-7 bg-ink flex items-center justify-center rounded-[3px]"
>
<span class="font-serif text-[14px] text-off-white leading-none"
>F</span
>
</div>
<span
class="font-serif text-[19px] text-ink tracking-tight leading-none"
>
Formfield<span class="text-warm-muted">.</span>
</span>
</a>
<span
class="hidden sm:block text-[10px] font-medium uppercase tracking-[1.8px] text-warm-muted"
>
Components
</span>
<div></div>
</div>
</div>
</nav>
<!-- TRIGGER PAGE -->
<main
class="min-h-[calc(100vh-64px)] flex flex-col items-center justify-center px-4 py-16"
>
<!-- Card -->
<div
class="bg-white border border-warm-mid rounded-[6px] p-10 sm:p-12 text-center w-full max-w-[440px]"
>
<p
class="text-[10px] font-medium uppercase tracking-[1.8px] text-warm-muted mb-3"
>
Modal Component
</p>
<h1
class="font-serif text-[1.8rem] text-ink leading-tight mb-3"
style="letter-spacing: -0.02em"
>
Get in Touch
</h1>
<p
class="text-[14px] text-warm-muted leading-relaxed mb-8 max-w-[260px] mx-auto"
>
Fill out the form and we'll get back to you within 24 hours.
</p>
<div class="flex flex-col gap-3">
<button
id="open-modal"
class="w-full flex items-center justify-center gap-2 px-5 py-[11px] bg-ink text-off-white text-[14px] font-medium rounded-[4px] border-none cursor-pointer hover:bg-ink-light transition-colors duration-150"
aria-haspopup="dialog"
>
Open Contact Form
<span
id="btn-arrow-trigger"
class="inline-block transition-transform duration-150"
>→</span
>
</button>
<button
id="open-modal-2"
class="w-full flex items-center justify-center gap-2 px-5 py-[11px] bg-transparent text-warm-muted text-[14px] font-normal rounded-[4px] border border-warm-mid cursor-pointer hover:text-ink hover:border-warm-border hover:bg-warm-bg transition-colors duration-150"
aria-haspopup="dialog"
>
Send us a message
</button>
</div>
<p class="text-[11px] text-warm-muted mt-6 uppercase tracking-[1.4px]">
Press
<kbd
class="bg-warm-bg border border-warm-mid rounded-[3px] px-1.5 py-px text-[10px] font-sans normal-case tracking-normal"
>Esc</kbd
>
to close
</p>
</div>
<!-- Feature pills -->
<div class="mt-8 flex items-center gap-5 flex-wrap justify-center">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-warm-muted"></div>
<span class="text-[11px] uppercase tracking-[1.4px] text-warm-muted"
>Real-time validation</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-warm-muted"></div>
<span class="text-[11px] uppercase tracking-[1.4px] text-warm-muted"
>Focus trap</span
>
</div>
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-warm-muted"></div>
<span class="text-[11px] uppercase tracking-[1.4px] text-warm-muted"
>Accessible</span
>
</div>
</div>
</main>
<!-- OVERLAY -->
<div
id="modal-overlay"
class="fixed inset-0 z-[200] opacity-0 pointer-events-none"
style="background: rgba(17, 17, 16, 0.45); backdrop-filter: blur(4px)"
role="presentation"
></div>
<!-- MODAL PANEL -->
<div
id="modal-panel"
class="fixed top-1/2 left-1/2 z-[300] w-full max-w-[480px] max-sm:max-w-[calc(100vw-32px)] bg-white border border-warm-mid rounded-[6px] opacity-0 pointer-events-none overflow-y-auto max-h-[90vh] px-6 max-sm:px-5"
style="
transform: translate(-50%, -46%);
box-shadow:
0 8px 40px rgba(17, 17, 16, 0.12),
0 2px 8px rgba(17, 17, 16, 0.06);
"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<!-- Header -->
<div
class="flex items-center justify-between py-5 sticky top-0 bg-white z-10 border-b border-warm-mid"
>
<div>
<p
class="text-[10px] font-medium uppercase tracking-[1.8px] text-warm-muted mb-1"
>
Contact Form
</p>
<h2
id="modal-title"
class="font-serif text-[1.3rem] text-ink leading-none"
style="letter-spacing: -0.01em"
>
Send a Message
</h2>
</div>
<button
id="close-modal"
class="w-8 h-8 flex items-center justify-center rounded-[4px] border border-warm-mid bg-transparent cursor-pointer hover:border-warm-border hover:bg-warm-bg transition-colors duration-150 flex-shrink-0"
aria-label="Close modal"
>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
<path
d="M1 1l9 9M10 1L1 10"
stroke="#888580"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<!-- Success state -->
<div
id="success-state"
class="hidden flex-col items-center justify-center text-center py-12 gap-4"
>
<div
class="w-12 h-12 border border-warm-mid rounded-[4px] flex items-center justify-center bg-ok-bg"
>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<path
d="M4 11l5 5L18 6"
stroke="#2d6a4f"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div>
<h3
class="font-serif text-[1.4rem] text-ink mb-2"
style="letter-spacing: -0.01em"
>
Message sent
</h3>
<p class="text-[13px] text-warm-muted leading-relaxed">
Thanks for reaching out. We'll get back to you within 24 hours.
</p>
</div>
<button
onclick="resetForm()"
class="mt-2 flex items-center gap-2 px-5 py-[9px] bg-transparent text-warm-muted text-[13px] font-normal rounded-[4px] border border-warm-mid cursor-pointer hover:text-ink hover:border-warm-border hover:bg-warm-bg transition-colors duration-150"
>
Send another
</button>
</div>
<!-- Form -->
<form id="form-body" novalidate autocomplete="off">
<div class="flex flex-col gap-5 py-6">
<!-- Name + Email grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Full Name -->
<div class="flex flex-col">
<label
class="flex items-center gap-0.5 text-[13px] font-medium text-ink mb-1.5 font-sans"
for="field-name"
>
Full Name
<span class="text-err text-[13px] ml-0.5" aria-hidden="true"
>*</span
>
</label>
<input
id="field-name"
type="text"
placeholder="Alex Johnson"
autocomplete="name"
aria-required="true"
aria-describedby="name-msg"
class="h-[42px] w-full bg-warm-bg border border-warm-mid rounded-[4px] text-[14px] text-ink px-3.5 placeholder:text-warm-muted font-sans outline-none transition-all duration-150 focus:border-ink focus:bg-white focus:shadow-focus"
/>
<p
id="name-msg"
class="mt-1.5 text-[12px] text-warm-muted font-sans min-h-[16px]"
role="alert"
aria-live="polite"
>
Your first and last name
</p>
</div>
<!-- Email -->
<div class="flex flex-col">
<label
class="flex items-center gap-0.5 text-[13px] font-medium text-ink mb-1.5 font-sans"
for="field-email"
>
Email Address
<span class="text-err text-[13px] ml-0.5" aria-hidden="true"
>*</span
>
</label>
<input
id="field-email"
type="email"
placeholder="alex@studio.co"
autocomplete="email"
aria-required="true"
aria-describedby="email-msg"
class="h-[42px] w-full bg-warm-bg border border-warm-mid rounded-[4px] text-[14px] text-ink px-3.5 placeholder:text-warm-muted font-sans outline-none transition-all duration-150 focus:border-ink focus:bg-white focus:shadow-focus"
/>
<p
id="email-msg"
class="mt-1.5 text-[12px] text-warm-muted font-sans min-h-[16px]"
role="alert"
aria-live="polite"
>
We'll never share your email
</p>
</div>
</div>
<!-- Password -->
<div class="flex flex-col">
<label
class="flex items-center gap-0.5 text-[13px] font-medium text-ink mb-1.5 font-sans"
for="field-password"
>
Password
<span class="text-err text-[13px] ml-0.5" aria-hidden="true"
>*</span
>
</label>
<div class="relative">
<input
id="field-password"
type="password"
placeholder="Create a password"
autocomplete="new-password"
aria-required="true"
aria-describedby="password-msg"
class="h-[42px] w-full bg-warm-bg border border-warm-mid rounded-[4px] text-[14px] text-ink pl-3.5 pr-10 placeholder:text-warm-muted font-sans outline-none transition-all duration-150 focus:border-ink focus:bg-white focus:shadow-focus"
/>
<button
type="button"
id="pw-toggle-btn"
aria-label="Show password"
class="absolute right-3 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer text-warm-muted hover:text-ink transition-colors duration-150 p-0.5 leading-none"
>
<svg
id="eye-icon"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<ellipse
cx="8"
cy="8"
rx="6.5"
ry="4"
stroke="currentColor"
stroke-width="1.2"
/>
<circle
cx="8"
cy="8"
r="2"
stroke="currentColor"
stroke-width="1.2"
/>
</svg>
<svg
id="eye-off-icon"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="hidden"
>
<path
d="M2 2l12 12"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
<path
d="M6.5 5.5A3.5 3.5 0 0 1 8 5c3 0 5.5 3 5.5 3s-.7 1.2-2 2.2"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
<path
d="M9.5 10.5A3.5 3.5 0 0 1 8 11c-3 0-5.5-3-5.5-3s.7-1.2 2-2.2"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<!-- Strength bar -->
<div
class="mt-2 h-[3px] w-full bg-warm-mid rounded-full overflow-hidden"
>
<div id="strength-fill" class="h-full rounded-full w-0"></div>
</div>
<p
id="password-msg"
class="mt-1.5 text-[12px] text-warm-muted font-sans min-h-[16px]"
role="alert"
aria-live="polite"
>
Minimum 8 characters
</p>
</div>
<!-- Phone (optional) -->
<div class="flex flex-col">
<label
class="flex items-center gap-1.5 text-[13px] font-medium text-ink mb-1.5 font-sans"
for="field-phone"
>
Phone
<span class="text-[11px] font-normal text-warm-muted"
>(optional)</span
>
</label>
<input
id="field-phone"
type="tel"
placeholder="+1 (555) 000-0000"
autocomplete="tel"
aria-describedby="phone-msg"
class="h-[42px] w-full bg-warm-bg border border-warm-mid rounded-[4px] text-[14px] text-ink px-3.5 placeholder:text-warm-muted font-sans outline-none transition-all duration-150 focus:border-ink focus:bg-white focus:shadow-focus"
/>
<p
id="phone-msg"
class="mt-1.5 text-[12px] text-warm-muted font-sans min-h-[16px]"
role="alert"
aria-live="polite"
>
Include country code
</p>
</div>
<!-- Message -->
<div class="flex flex-col">
<label
class="flex items-center gap-0.5 text-[13px] font-medium text-ink mb-1.5 font-sans"
for="field-message"
>
Message
<span class="text-err text-[13px] ml-0.5" aria-hidden="true"
>*</span
>
</label>
<textarea
id="field-message"
placeholder="Tell us about your project, timeline, and budget…"
maxlength="500"
aria-required="true"
aria-describedby="message-msg"
class="w-full min-h-[96px] resize-y bg-warm-bg border border-warm-mid rounded-[4px] text-[14px] text-ink px-3.5 py-3 placeholder:text-warm-muted font-sans leading-relaxed outline-none transition-all duration-150 focus:border-ink focus:bg-white focus:shadow-focus"
></textarea>
<div class="flex items-start justify-between mt-1.5">
<p
id="message-msg"
class="text-[12px] text-warm-muted font-sans min-h-[16px]"
role="alert"
aria-live="polite"
>
Minimum 20 characters
</p>
<span
id="char-counter"
class="text-[11px] text-warm-muted font-sans flex-shrink-0"
>
0 / 500
</span>
</div>
</div>
<!-- Divider -->
<div class="h-px bg-warm-mid w-full"></div>
<!-- Terms -->
<div class="flex items-start gap-3">
<div class="relative flex-shrink-0 mt-px">
<input
type="checkbox"
id="field-terms"
aria-required="true"
aria-describedby="terms-msg"
class="form-checkbox w-4 h-4 appearance-none border border-warm-mid rounded-[3px] bg-warm-bg cursor-pointer transition-all duration-150 checked:bg-ink checked:border-ink focus:outline-none focus:shadow-focus relative"
/>
</div>
<div>
<label
for="field-terms"
class="text-[13px] text-warm-muted leading-relaxed cursor-pointer hover:text-ink transition-colors duration-150 font-sans"
>
I agree to the
<a
href="#"
class="text-ink underline underline-offset-[2px] hover:text-warm-muted transition-colors duration-150"
>Terms of Service</a
>
and
<a
href="#"
class="text-ink underline underline-offset-[2px] hover:text-warm-muted transition-colors duration-150"
>Privacy Policy</a
>
</label>
<p
id="terms-msg"
class="hidden mt-1 text-[12px] text-err font-sans"
role="alert"
aria-live="polite"
></p>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex flex-col gap-3 pb-6 pt-5 border-t border-warm-mid">
<!-- Submit -->
<button
type="submit"
id="submit-btn"
class="w-full flex items-center justify-center gap-2 px-5 py-[11px] bg-ink text-off-white text-[14px] font-medium rounded-[4px] border-none cursor-pointer hover:bg-ink-light transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span id="btn-label">Send Message</span>
<span
id="btn-arrow"
class="inline-block transition-transform duration-150"
>→</span
>
<span
id="btn-spinner"
class="hidden w-4 h-4 border-2 rounded-full animate-spin flex-shrink-0"
style="
border-color: rgba(245, 244, 241, 0.3);
border-top-color: #f5f4f1;
"
>
</span>
</button>
<!-- Cancel -->
<button
type="button"
id="cancel-btn"
class="w-full flex items-center justify-center gap-2 px-5 py-[11px] bg-transparent text-warm-muted text-[14px] font-normal rounded-[4px] border border-warm-mid cursor-pointer hover:text-ink hover:border-warm-border hover:bg-warm-bg transition-colors duration-150"
>
Cancel
</button>
<!-- Status note -->
<p
class="text-[11px] text-center text-warm-muted uppercase tracking-[1.4px] font-sans"
>
We respond within 24 hours
</p>
</div>
</form>
</div>
<!-- Link JavaScript -->
<script src="app.js"></script>
</body>
</html>Component details
Overview, install command, dependencies, and project links.
A centered modal popup with a fully validated contact form — built with HTML, Tailwind CSS, and vanilla JavaScript. No frameworks, no libraries, just clean utility-first code. The modal opens from a trigger button, animates in with a spring transition, and traps focus inside for full keyboard accessibility. The form has five fields: full name, email, password, phone (optional), and a message textarea. Every required field validates on blur first, then switches to live input validation so it doesn't yell at you before you've finished typing. Password has a show/hide toggle and a 5-level strength bar that shifts from red to green. The textarea tracks characters live with a counter that warns at 450/500. On submit it validates everything at once, focuses the first error automatically, shows a loading spinner, and transitions to a success state after the async call. Resetting brings everything cleanly back to zero. Tailwind handles all styling — layout, color, states, responsive, transitions. Custom CSS is kept to exactly 4 things that Tailwind genuinely cannot do: the modal's dual-easing open/close transition, the strength bar width + color transition, the checkbox checkmark pseudo-element, and scrollbar styling.
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation