Formulaire de contact en verre dépoli avec effets “liquide” (blobs animés), typographie soignée et focus rings façon iOS/macOS. 100% vanilla (aucune dépendance), responsive, accessible (labels, aria-live, contraste), anti-spam (honeypot) et respect de prefers-reduced-motion.
Pour l’envoi d’e-mail sans backend, remplacez l’ID Formspree (YOUR_FORMSPREE_ID) par le vôtre ; sinon, branchez votre propre endpoint d’API.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Formulaire — Liquid Glass (style Apple)</title>
<!--
Plug & Play — Formulaire "Liquid Glass" (style Apple)
-----------------------------------------------------
• Déposez ce fichier tel quel dans votre site (ou copiez le <section id="contact">).
• Pour l'envoi d'email sans backend, remplacez YOUR_FORMSPREE_ID ci‑dessous.
• Accessibilité, validation, anti‑spam (honeypot), respect de prefers-reduced-motion.
-->
<style>
:root {
--glass-bg: rgba(255, 255, 255, .28);
--glass-brd: rgba(255, 255, 255, .45);
--glass-blur: 22px;
--text: #0b1220;
--muted: #5b6170;
--ring: #3b82f6; /* bleu doux façon iOS */
--ok: #0ea5e9;
--error: #ef4444;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", Roboto, Arial, sans-serif;
/* Fond dégradé avec halo, proche des visuels Apple */
background: radial-gradient(1200px 800px at 20% -10%, #a5b4fc55, transparent 60%),
radial-gradient(1000px 700px at 120% 40%, #67e8f955, transparent 60%),
linear-gradient(135deg, #0f172a 0%, #111827 100%);
}
.wrap { min-height: 100%; display: grid; place-items: center; padding: clamp(16px, 3vw, 32px); }
/* Blobs liquides en arrière-plan */
.blobs { position: fixed; inset: -10% -10% auto -10%; pointer-events: none; z-index: 0; filter: blur(40px); opacity: .9; }
.blob { position: absolute; width: 42vmin; height: 42vmin; border-radius: 40% 60% 55% 45% / 55% 45% 55% 45%; mix-blend-mode: screen; }
.blob:nth-child(1){ background: #60a5fa; top: 8%; left: 8%; animation: float 16s ease-in-out infinite; }
.blob:nth-child(2){ background: #a78bfa; top: 20%; right: 12%; animation: float 20s ease-in-out infinite reverse; }
.blob:nth-child(3){ background: #34d399; bottom: 8%; left: 18%; animation: float 24s ease-in-out infinite; }
@keyframes float { 0%,100%{ transform: translate3d(0,0,0) scale(1); } 50%{ transform: translate3d(0,-3%,0) scale(1.06); } }
/* Carte verre dépoli */
.card {
position: relative; z-index: 1; width: min(720px, 92vw);
background: var(--glass-bg);
border: 1px solid var(--glass-brd);
border-radius: 28px;
box-shadow: 0 10px 40px rgba(0,0,0,.25), inset 0 1px 0 rgba(255,255,255,.25);
backdrop-filter: saturate(160%) blur(var(--glass-blur));
-webkit-backdrop-filter: saturate(160%) blur(var(--glass-blur));
overflow: hidden;
}
.header { padding: clamp(18px, 3vw, 28px); border-bottom: 1px solid rgba(255,255,255,.35); }
h1 { margin: 0; font-size: clamp(1.25rem, 2.2vw, 1.6rem); color: #ffffff; letter-spacing: .2px; }
p.sub { margin: 6px 0 0; color: #e5e7eb; opacity: .9; font-size: .95rem; }
form { display: grid; gap: 16px; padding: clamp(18px, 3vw, 28px); }
.row { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; }
label { color: #e5e7eb; font-weight: 600; font-size: .95rem; }
.field { display: grid; gap: 8px; }
input, textarea {
appearance: none; width: 100%; color: #e5e7eb;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.28);
border-radius: 16px; padding: 14px 16px;
outline: none; transition: box-shadow .2s ease, border-color .2s ease, background .2s ease;
backdrop-filter: blur(12px);
}
input::placeholder, textarea::placeholder { color: #cbd5e1; opacity: .7; }
input:focus, textarea:focus {
border-color: rgba(59,130,246,.75);
box-shadow: 0 0 0 4px rgba(59,130,246,.25), inset 0 1px 0 rgba(255,255,255,.2);
background: rgba(255,255,255,.12);
}
.hint { color: #cbd5e1; opacity: .9; font-size: .9rem; }
.actions { display: flex; gap: 12px; align-items: center; margin-top: 6px; }
button {
appearance: none; border: 1px solid rgba(255,255,255,.35);
background: linear-gradient(180deg, #ffffffcc, #dbeafeaa);
color: #0b1220; font-weight: 700; letter-spacing: .2px;
padding: 12px 18px; border-radius: 14px; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 10px 16px rgba(59,130,246,.25);
transition: transform .06s ease, box-shadow .2s ease, filter .2s ease;
}
button:hover { transform: translateY(-1px); }
button:active { transform: translateY(0); box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 6px 12px rgba(59,130,246,.25); }
button[disabled] { opacity: .7; cursor: not-allowed; filter: grayscale(.2); }
.status { font-size: .95rem; color: #e5e7eb; }
.ok { color: #86efac; }
.err { color: #fecaca; }
/* Badge discret en haut‑droite */
.badge {
position: absolute; top: 10px; right: 12px; font-size: .8rem; color: #e5e7eb;
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.25);
padding: 6px 10px; border-radius: 999px; backdrop-filter: blur(8px);
}
/* Responsive */
@media (max-width: 680px) { .row { grid-template-columns: 1fr; } }
/* Réduction de mouvement */
@media (prefers-reduced-motion: reduce) {
.blob { animation: none; }
button, input, textarea { transition: none; }
}
</style>
</head>
<body>
<div class="wrap">
<!-- Blobs liquides -->
<div class="blobs" aria-hidden="true">
<div class="blob"></div>
<div class="blob"></div>
<div class="blob"></div>
</div>
<!-- Carte verre dépoli -->
<section class="card" role="region" aria-labelledby="title" id="contact">
<div class="badge" aria-hidden="true">Liquid Glass</div>
<div class="header">
<h1 id="title">Nous contacter</h1>
<p class="sub">Un formulaire élégant en verre dépoli, façon iOS/macOS.</p>
</div>
<form id="contact-form" novalidate>
<div class="row">
<div class="field">
<label for="name">Nom</label>
<input id="name" name="name" type="text" placeholder="Votre nom" autocomplete="name" required />
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="vous@domaine.com" autocomplete="email" required />
</div>
</div>
<div class="field">
<label for="subject">Sujet</label>
<input id="subject" name="subject" type="text" placeholder="Sujet du message" required />
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" placeholder="Votre message…" required></textarea>
<div class="hint">En envoyant, vous acceptez d'être recontacté concernant votre demande.</div>
</div>
<!-- Honeypot anti‑spam -->
<div style="position:absolute; left:-5000px" aria-hidden="true">
<label for="website">Ne pas remplir</label>
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="actions">
<button type="submit" id="sendBtn">Envoyer</button>
<span class="status" id="status" aria-live="polite"></span>
</div>
</form>
</section>
</div>
<script>
// Remplacez par votre endpoint (Formspree, votre API, etc.)
const FORMSPREE_ENDPOINT = "https://formspree.io/f/YOUR_FORMSPREE_ID";
const form = document.getElementById('contact-form');
const statusEl = document.getElementById('status');
const sendBtn = document.getElementById('sendBtn');
function validEmail(v){ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
form.addEventListener('submit', async (e) => {
e.preventDefault();
statusEl.textContent = '';
const data = new FormData(form);
// honeypot
if ((data.get('website')||'').trim() !== '') {
statusEl.textContent = 'Envoi bloqué.';
statusEl.className = 'status err';
return;
}
const name = (data.get('name')||'').trim();
const email = (data.get('email')||'').trim();
const subject = (data.get('subject')||'').trim();
const message = (data.get('message')||'').trim();
if (!name || !email || !subject || !message) {
statusEl.textContent = 'Veuillez remplir tous les champs requis.';
statusEl.className = 'status err';
return;
}
if (!validEmail(email)) {
statusEl.textContent = 'Adresse email invalide.';
statusEl.className = 'status err';
return;
}
sendBtn.disabled = true; sendBtn.textContent = 'Envoi…';
try {
data.append('_replyto', email);
data.append('_subject', subject || 'Nouveau message via formulaire');
const res = await fetch(FORMSPREE_ENDPOINT, { method: 'POST', body: data, headers: { 'Accept': 'application/json' } });
if (res.ok) {
form.reset();
statusEl.textContent = 'Message envoyé. Merci !';
statusEl.className = 'status ok';
} else {
const info = await res.json().catch(() => ({}));
const msg = (info && (info.error || info.message)) || 'Une erreur est survenue.';
statusEl.textContent = msg; statusEl.className = 'status err';
}
} catch (err) {
statusEl.textContent = 'Réseau indisponible. Réessayez plus tard.';
statusEl.className = 'status err';
} finally {
sendBtn.disabled = false; sendBtn.textContent = 'Envoyer';
}
});
</script>
</body>
</html>